Compare commits

..

37 Commits

Author SHA1 Message Date
Aiden Cline d2f7145bf9 fix(ai): preserve stream transport failures 2026-08-11 16:21:21 +00:00
Kit Langton 23b0688a7f fix(server): await models before listing (#41783) 2026-08-11 12:18:07 -04:00
opencode-agent[bot] d0dd49bd84 chore: generate 2026-08-11 16:12:58 +00:00
Kit Langton db9a3b6c41 refactor(core): compact question tool schema (#41772) 2026-08-11 12:09:37 -04:00
opencode-agent[bot] 961b51b509 fix(core): yield while clearing migration events (#41775)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-08-11 16:02:30 +00:00
opencode-agent[bot] 964f7f4254 chore: generate 2026-08-11 15:53:52 +00:00
Aiden Cline 8c27c8485e feat(session): persist previous selections (#41771) 2026-08-11 10:52:14 -05:00
opencode-agent[bot] 6721ff5328 fix(core): omit deprecated models.dev models (#41769)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-11 10:17:04 -05:00
Kit Langton 026e0c634f refactor(core): reuse plugin directory names (#41754) 2026-08-11 10:40:36 -04:00
Kit Langton c581b59b7f refactor(merman): simplify spatial span assembly (#41757) 2026-08-11 10:40:22 -04:00
Kit Langton 5cc7811ec2 refactor(core): defer database global lookup (#41755) 2026-08-11 10:40:03 -04:00
Kit Langton 5d32845b02 refactor(tui): simplify marquee reset (#41758) 2026-08-11 10:39:51 -04:00
Kit Langton e9f5842f29 refactor: simplify attachment identity tracking (#41756) 2026-08-11 10:39:42 -04:00
Kit Langton 7b9734593a refactor(core): generate static imports for the migration manifest (#41657) 2026-08-11 10:35:19 -04:00
opencode-agent[bot] 1dbfc7cfab chore: generate 2026-08-11 14:12:14 +00:00
Kit Langton 3441b95afc fix(core): models.dev catalog population must survive KV cache write failures (#41735) 2026-08-11 10:09:37 -04:00
opencode-agent[bot] 306204bad8 fix(app): handle untitled session tab info (#41700)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-11 18:02:10 +10:00
Aiden Cline 47c8d85904 feat(plugin): allow tool hooks to fail with tool errors (#41668) 2026-08-11 00:36:59 -05:00
Aiden Cline c401076b6f fix(core): preserve AI SDK tool media (#41672) 2026-08-11 00:33:33 -05:00
Luke Parker 1aef4de853 chore(desktop): skip unused beta CLI builds (#41673) 2026-08-11 15:07:21 +10:00
opencode-agent[bot] 9c94634515 fix(tui): use active model for compaction (#41608)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-10 23:50:51 -05:00
Luke Parker c2b754bb03 feat(desktop): publish v2 beta desktop (#41626) 2026-08-11 14:33:31 +10:00
Aiden Cline 2372edd5eb feat(tui): show previous agent in switch notices (#41661) 2026-08-10 23:09:26 -05:00
Kit Langton a5f7f8d3b5 fix(tui): deduplicate repeated image attachments (#41651) 2026-08-10 23:39:38 -04:00
Aiden Cline 4df276b9e8 feat(session): persist previous agent on switch (#41621) 2026-08-10 22:35:16 -05:00
Kit Langton 518af92c5b refactor(core): move the models.dev catalog cache from disk to KV (#41649) 2026-08-10 22:40:41 -04:00
Kit Langton 5d8c48718b refactor(core): move instruction discovery to the config side (#41629) 2026-08-10 22:32:58 -04:00
Kit Langton 41c5853739 refactor: route Global path consumers through the service (#41632) 2026-08-10 21:37:09 -04:00
Kit Langton 8d989f6829 fix(tui): collapse execute child details (#41624) 2026-08-10 21:20:51 -04:00
Kit Langton 753fa4cd60 fix(core): isolate config plugin tests from ambient user config (#41631) 2026-08-11 01:16:08 +00:00
opencode-agent[bot] d99a268ea5 chore: generate 2026-08-11 00:39:33 +00:00
Kit Langton 1113adfd5e refactor(core): skill service stores values, config plugin owns the filesystem (#41622) 2026-08-10 20:38:08 -04:00
Kit Langton 6afff5a55f fix(util): no filesystem side effects at global module load (#41619) 2026-08-10 20:38:04 -04:00
Kit Langton 71f5e4189d refactor(core): move plugin discovery and watching to the config side (#41618) 2026-08-10 20:15:08 -04:00
Kit Langton b49bad9a86 fix(merman): remove sequence lifeline fade (#41623) 2026-08-10 19:48:28 -04:00
Kit Langton 2580f880a8 feat(merman): refine sequence diagram styling (#41617) 2026-08-10 19:12:46 -04:00
Kit Langton 9d34029cd9 fix(core): runtime-neutral legacy credential import (#41607) 2026-08-10 18:35:52 -04:00
140 changed files with 3619 additions and 2040 deletions
+13 -10
View File
@@ -75,7 +75,7 @@ jobs:
build-cli: build-cli:
needs: version needs: version
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'anomalyco/opencode' if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
steps: steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
with: with:
@@ -91,7 +91,7 @@ jobs:
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Build legacy CLI - name: Build legacy CLI
if: github.ref_name != 'v2' if: github.ref_name != 'v2' && github.ref_name != 'beta'
run: ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }} run: ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env: env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_VERSION: ${{ needs.version.outputs.version }}
@@ -109,7 +109,7 @@ jobs:
GH_TOKEN: ${{ steps.committer.outputs.token }} GH_TOKEN: ${{ steps.committer.outputs.token }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: github.ref_name != 'v2' if: github.ref_name != 'v2' && github.ref_name != 'beta'
with: with:
name: opencode-cli name: opencode-cli
path: | path: |
@@ -117,7 +117,7 @@ jobs:
packages/opencode/dist/opencode-linux* packages/opencode/dist/opencode-linux*
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: github.ref_name != 'v2' if: github.ref_name != 'v2' && github.ref_name != 'beta'
with: with:
name: opencode-cli-windows name: opencode-cli-windows
path: packages/opencode/dist/opencode-windows* path: packages/opencode/dist/opencode-windows*
@@ -132,7 +132,7 @@ jobs:
build-node-cli: build-node-cli:
needs: version needs: version
if: github.repository == 'anomalyco/opencode' if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -184,7 +184,7 @@ jobs:
- build-cli - build-cli
- version - version
runs-on: blacksmith-4vcpu-windows-2025 runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' && github.ref_name != 'beta'
env: env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -377,7 +377,7 @@ jobs:
env: env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
RUST_TARGET: ${{ matrix.settings.target }} OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
- name: Build - name: Build
run: bun run build run: bun run build
@@ -393,6 +393,7 @@ jobs:
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }} VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }} VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }}
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }} VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
- name: Package - name: Package
if: needs.version.outputs.release if: needs.version.outputs.release
@@ -496,29 +497,31 @@ jobs:
registry-url: "https://registry.npmjs.org" registry-url: "https://registry.npmjs.org"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2' if: github.ref_name != 'v2' && github.ref_name != 'beta'
with: with:
name: opencode-cli name: opencode-cli
path: packages/opencode/dist path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2' if: github.ref_name != 'v2' && github.ref_name != 'beta'
with: with:
name: opencode-cli-windows name: opencode-cli-windows
path: packages/opencode/dist path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2' if: github.ref_name != 'v2' && github.ref_name != 'beta'
with: with:
name: opencode-cli-signed-windows name: opencode-cli-signed-windows
path: packages/opencode/dist path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'beta'
with: with:
name: opencode-preview-cli name: opencode-preview-cli
path: packages/cli/dist path: packages/cli/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'beta'
with: with:
pattern: opencode-node-cli-* pattern: opencode-node-cli-*
path: packages/cli/dist/node path: packages/cli/dist/node
+1
View File
@@ -439,6 +439,7 @@
"@actions/artifact": "4.0.0", "@actions/artifact": "4.0.0",
"@lydell/node-pty": "catalog:", "@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:", "@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:", "@sentry/vite-plugin": "catalog:",
@@ -1,19 +0,0 @@
import type { ProviderPackage } from "../provider-package"
import type { OpenAIProviderOptionsInput } from "./openai-options"
import { CloudflareWorkersAI } from "./cloudflare"
export interface Settings extends ProviderPackage.Settings {
readonly accountId?: string
readonly apiKey?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
CloudflareWorkersAI.configure({
...(typeof settings.baseURL === "string" ? { baseURL: settings.baseURL } : { accountId: settings.accountId ?? "" }),
apiKey: settings.apiKey,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
+65 -32
View File
@@ -1,4 +1,4 @@
import { Cause, Context, Effect, Layer, Option, Schema } from "effect" import { Cause, Context, Effect, Layer, Option, Schema, Stream } from "effect"
import { import {
FetchHttpClient, FetchHttpClient,
Headers, Headers,
@@ -16,12 +16,17 @@ import {
TransportReason, TransportReason,
} from "../schema" } from "../schema"
import { classifyProviderFailure } from "../provider-error" import { classifyProviderFailure } from "../provider-error"
import { isRecord } from "../utils/record"
export interface Interface { export interface Interface {
readonly execute: ( readonly execute: (
request: HttpClientRequest.HttpClientRequest, request: HttpClientRequest.HttpClientRequest,
middleware?: HttpMiddleware, middleware?: HttpMiddleware,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, AIError> ) => Effect.Effect<HttpClientResponse.HttpClientResponse, AIError>
readonly stream: (
request: HttpClientRequest.HttpClientRequest,
middleware?: HttpMiddleware,
) => Stream.Stream<Uint8Array, AIError>
} }
export type HttpHandler = ( export type HttpHandler = (
@@ -297,41 +302,51 @@ export const classifyHttpFailure = (input: {
}) })
} }
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => { type HttpOperation = "request" | "read"
const transportError = (input: {
readonly message: string const httpError = (input: {
readonly kind?: string | undefined readonly error: unknown
readonly request?: HttpClientRequest.HttpClientRequest | undefined readonly request: HttpClientRequest.HttpClientRequest
}) => readonly operation: HttpOperation
readonly redactedNames: ReadonlyArray<string | RegExp>
}) => {
const transportError = (failure: { readonly message: string; readonly code?: string | undefined }) =>
new AIError({ new AIError({
module: "RequestExecutor", module: "RequestExecutor",
method: "execute", method: input.operation,
reason: new TransportReason({ reason: new TransportReason({
message: input.message, message: failure.message,
kind: input.kind, transport: "http",
url: input.request ? redactUrl(input.request.url) : undefined, operation: input.operation,
http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined, code: failure.code,
url: redactUrl(input.request.url),
http: new HttpContext({ request: requestDetails(input.request, input.redactedNames) }),
}), }),
}) })
if (Cause.isTimeoutError(error)) { const source =
return transportError({ message: error.message, kind: "Timeout" }) HttpClientError.isHttpClientError(input.error) && "cause" in input.error.reason
} ? input.error.reason.cause
if (!HttpClientError.isHttpClientError(error)) { : input.error
return transportError({ message: error instanceof Error ? error.message : "HTTP transport failed" }) const code = isRecord(source) && typeof source.code === "string" ? source.code : undefined
} const request = HttpClientError.isHttpClientError(input.error) ? input.error.request : input.request
const request = "request" in error ? error.request : undefined const raw = source instanceof Error ? source.message : input.error instanceof Error ? input.error.message : undefined
if (error.reason._tag === "TransportError") { const detail = raw ? redactBody(raw, secretValues(request)) : undefined
const message = code && detail && !detail.includes(code) ? `${code}: ${detail}` : detail
if (Cause.isTimeoutError(input.error) || Cause.isTimeoutError(source))
return transportError({ message: message ?? "HTTP transport timed out", code: code ?? "Timeout" })
if (!HttpClientError.isHttpClientError(input.error))
return transportError({ message: message ?? "HTTP transport failed", code })
if (input.error.reason._tag === "TransportError") {
return transportError({ return transportError({
message: error.reason.description ?? "HTTP transport failed", message: message ?? input.error.reason.description ?? "HTTP transport failed",
kind: error.reason._tag, code: code ?? input.error.reason._tag,
request,
}) })
} }
return transportError({ return transportError({
message: `HTTP transport failed: ${error.reason._tag}`, message: message ?? `HTTP transport failed: ${input.error.reason._tag}`,
kind: error.reason._tag, code: code ?? input.error.reason._tag,
request,
}) })
} }
@@ -339,23 +354,41 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const http = yield* HttpClient.HttpClient const http = yield* HttpClient.HttpClient
const executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) => const execute = (
request: HttpClientRequest.HttpClientRequest,
middleware: HttpMiddleware | undefined,
redactedNames: ReadonlyArray<string | RegExp>,
) =>
Effect.gen(function* () { Effect.gen(function* () {
const redactedNames = yield* Headers.CurrentRedactedNames
if (!middleware) if (!middleware)
return yield* http return yield* http.execute(request).pipe(
.execute(request) Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })),
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames))) Effect.flatMap(statusError(request, redactedNames)),
)
const response = yield* middleware(request, (input) => const response = yield* middleware(request, (input) =>
http http
.execute(input) .execute(input)
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))), .pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
).pipe(Effect.mapError(toHttpError(redactedNames))) ).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })))
return yield* statusError(response.request, redactedNames)(response) return yield* statusError(response.request, redactedNames)(response)
}) })
const executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) =>
Effect.flatMap(Headers.CurrentRedactedNames, (redactedNames) => execute(request, middleware, redactedNames))
return Service.of({ return Service.of({
execute: executeOnce, execute: executeOnce,
stream: (request, middleware) =>
Stream.unwrap(
Effect.gen(function* () {
const redactedNames = yield* Headers.CurrentRedactedNames
const response = yield* execute(request, middleware, redactedNames)
return response.stream.pipe(
Stream.mapError((error) =>
httpError({ error, request: response.request, operation: "read", redactedNames }),
),
)
}),
),
}) })
}), }),
) )
+3 -21
View File
@@ -1,4 +1,4 @@
import { Effect, Stream } from "effect" import { Effect } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http" import { Headers, HttpClientRequest } from "effect/unstable/http"
import { Auth } from "../auth" import { Auth } from "../auth"
import { render as renderEndpoint } from "../endpoint" import { render as renderEndpoint } from "../endpoint"
@@ -86,26 +86,8 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
middleware: prepareInput.middleware, middleware: prepareInput.middleware,
} }
}), }),
frames: (prepared, request, runtime) => frames: (prepared, _request, runtime) =>
Stream.unwrap( prepared.framing.frame(runtime.http.stream(prepared.request, prepared.middleware)),
runtime.http
.execute(prepared.request, prepared.middleware)
.pipe(
Effect.map((response) =>
prepared.framing.frame(
response.stream.pipe(
Stream.mapError((error) =>
ProviderShared.eventError(
`${request.model.provider}/${request.model.route.id}`,
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
ProviderShared.errorText(error),
),
),
),
),
),
),
),
}) })
export const sseJson = { export const sseJson = {
+36 -13
View File
@@ -1,6 +1,6 @@
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect" import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http" import { Headers } from "effect/unstable/http"
import { AIError, TransportReason } from "../../schema" import { AIError, TransportReason, type TransportOperation } from "../../schema"
import * as HttpTransport from "./http" import * as HttpTransport from "./http"
import type { Transport } from "./index" import type { Transport } from "./index"
@@ -29,12 +29,18 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/AI
const transportError = ( const transportError = (
method: string, method: string,
message: string, message: string,
input: { readonly url?: string; readonly kind?: string } = {}, input: { readonly operation: TransportOperation; readonly url?: string; readonly code?: string },
) => ) =>
new AIError({ new AIError({
module: "WebSocketExecutor", module: "WebSocketExecutor",
method, method,
reason: new TransportReason({ message, url: input.url, kind: input.kind }), reason: new TransportReason({
message,
transport: "websocket",
operation: input.operation,
url: input.url,
code: input.code,
}),
}) })
const eventMessage = (event: Event) => { const eventMessage = (event: Event) => {
@@ -55,7 +61,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
return Effect.fail( return Effect.fail(
transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, { transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, {
url: input.url, url: input.url,
kind: "open", operation: "request",
code: "closed",
}), }),
) )
} }
@@ -79,7 +86,10 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
cleanup() cleanup()
resume( resume(
Effect.fail( Effect.fail(
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, { url: input.url, kind: "open" }), transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, {
url: input.url,
operation: "request",
}),
), ),
) )
} }
@@ -89,7 +99,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
Effect.fail( Effect.fail(
transportError("open", `WebSocket closed before opening with code ${event.code}`, { transportError("open", `WebSocket closed before opening with code ${event.code}`, {
url: input.url, url: input.url,
kind: "open", operation: "request",
code: String(event.code),
}), }),
), ),
) )
@@ -118,7 +129,8 @@ const webSocketUrl = (value: string) =>
catch: (error) => catch: (error) =>
transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", { transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", {
url: value, url: value,
kind: "websocket", operation: "request",
code: "invalid-url",
}), }),
}) })
@@ -129,7 +141,7 @@ export const open = (input: WebSocketRequest) =>
catch: (error) => catch: (error) =>
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", { transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
url: input.url, url: input.url,
kind: "open", operation: "request",
}), }),
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input))) }).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
@@ -150,7 +162,10 @@ export const fromWebSocket = (
Queue.failCauseUnsafe( Queue.failCauseUnsafe(
messages, messages,
Cause.fail( Cause.fail(
transportError("message", "Unsupported WebSocket message payload", { url: input.url, kind: "message" }), transportError("message", "Unsupported WebSocket message payload", {
url: input.url,
operation: "read",
}),
), ),
) )
} }
@@ -158,7 +173,10 @@ export const fromWebSocket = (
Queue.failCauseUnsafe( Queue.failCauseUnsafe(
messages, messages,
Cause.fail( Cause.fail(
transportError("message", `WebSocket error: ${eventMessage(event)}`, { url: input.url, kind: "message" }), transportError("message", `WebSocket error: ${eventMessage(event)}`, {
url: input.url,
operation: "read",
}),
), ),
) )
} }
@@ -167,7 +185,11 @@ export const fromWebSocket = (
Queue.failCauseUnsafe( Queue.failCauseUnsafe(
messages, messages,
Cause.fail( Cause.fail(
transportError("message", `WebSocket closed with code ${event.code}`, { url: input.url, kind: "close" }), transportError("message", `WebSocket closed with code ${event.code}`, {
url: input.url,
operation: "read",
code: String(event.code),
}),
), ),
) )
} }
@@ -188,7 +210,7 @@ export const fromWebSocket = (
catch: (error) => catch: (error) =>
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", { transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
url: input.url, url: input.url,
kind: "write", operation: "write",
}), }),
}), }),
messages: Stream.fromQueue(messages), messages: Stream.fromQueue(messages),
@@ -243,7 +265,8 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
return Stream.fail( return Stream.fail(
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", { transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
url: prepared.url, url: prepared.url,
kind: "websocket", operation: "request",
code: "unavailable",
}), }),
) )
} }
+9 -1
View File
@@ -92,10 +92,18 @@ export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>
http: Schema.optional(HttpContext), http: Schema.optional(HttpContext),
}) {} }) {}
export const TransportType = Schema.Literals(["http", "websocket"])
export type TransportType = typeof TransportType.Type
export const TransportOperation = Schema.Literals(["request", "read", "write"])
export type TransportOperation = typeof TransportOperation.Type
export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Transport")({ export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Transport")({
_tag: Schema.tag("Transport"), _tag: Schema.tag("Transport"),
message: Schema.String, message: Schema.String,
kind: Schema.optional(Schema.String), transport: TransportType,
operation: TransportOperation,
code: Schema.optional(Schema.String),
url: Schema.optional(Schema.String), url: Schema.optional(Schema.String),
http: Schema.optional(HttpContext), http: Schema.optional(HttpContext),
}) {} }) {}
+31 -2
View File
@@ -1,10 +1,10 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer, Ref } from "effect" import { Effect, Layer, Ref, Stream } from "effect"
import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, AIError } from "../src" import { LLM, AIError } from "../src"
import { LLMClient, RequestExecutor } from "../src/route" import { LLMClient, RequestExecutor } from "../src/route"
import * as OpenAIChat from "../src/protocols/openai-chat" import * as OpenAIChat from "../src/protocols/openai-chat"
import { dynamicResponse } from "./lib/http" import { dynamicResponse, systemError } from "./lib/http"
import { deltaChunk } from "./lib/openai-chunks" import { deltaChunk } from "./lib/openai-chunks"
import { sseRaw } from "./lib/sse" import { sseRaw } from "./lib/sse"
import { it } from "./lib/effect" import { it } from "./lib/effect"
@@ -67,6 +67,35 @@ const expectAIError = (error: unknown) => {
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined) const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
describe("RequestExecutor", () => { describe("RequestExecutor", () => {
it.effect("parses response body failures at the executor seam", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.stream(secretRequest).pipe(Stream.runDrain, Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: disconnected <redacted> <redacted>",
transport: "http",
operation: "read",
code: "ECONNRESET",
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&debug=1",
})
}).pipe(
Effect.provide(
responsesLayer([
new Response(
new ReadableStream({
start(controller) {
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
},
}),
),
]),
),
),
)
it.effect("preserves middleware error messages", () => it.effect("preserves middleware error messages", () =>
Effect.gen(function* () { Effect.gen(function* () {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
+8 -2
View File
@@ -34,6 +34,12 @@ const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
export type RuntimeEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService export type RuntimeEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
export interface SystemError extends Error {
readonly code: string
}
export const systemError = (code: string, message: string): SystemError => Object.assign(new Error(message), { code })
export const runtimeLayer = (layer: Layer.Layer<HttpClient.HttpClient>): Layer.Layer<RuntimeEnv> => { export const runtimeLayer = (layer: Layer.Layer<HttpClient.HttpClient>): Layer.Layer<RuntimeEnv> => {
const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer)) const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer))
const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer) const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
@@ -63,14 +69,14 @@ export const dynamicResponse = (handler: Handler) => runtimeLayer(handlerLayer(h
* Layer that emits the supplied SSE chunks and then aborts mid-stream. Used to * Layer that emits the supplied SSE chunks and then aborts mid-stream. Used to
* exercise transport errors that surface during parsing. * exercise transport errors that surface during parsing.
*/ */
export const truncatedStream = (chunks: ReadonlyArray<string>) => export const truncatedStream = (chunks: ReadonlyArray<string>, error: Error = new Error("connection reset")) =>
dynamicResponse((input) => dynamicResponse((input) =>
Effect.sync(() => { Effect.sync(() => {
const encoder = new TextEncoder() const encoder = new TextEncoder()
const stream = new ReadableStream({ const stream = new ReadableStream({
start(controller) { start(controller) {
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)) for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
controller.error(new Error("connection reset")) controller.error(error)
}, },
}) })
return input.respond(stream, { headers: SSE_HEADERS }) return input.respond(stream, { headers: SSE_HEADERS })
@@ -1,16 +0,0 @@
import { describe, expect, test } from "bun:test"
import { model } from "../../src/providers/cloudflare-workers-ai"
describe("Cloudflare Workers AI provider package", () => {
test("derives the endpoint from accountId", () => {
const resolved = model("@cf/model", { accountId: "account", apiKey: "secret" })
expect(resolved.route.endpoint.baseURL).toBe("https://api.cloudflare.com/client/v4/accounts/account/ai/v1")
})
test("preserves an explicit endpoint", () => {
const resolved = model("@cf/model", { baseURL: "https://proxy.example/v1", apiKey: "secret" })
expect(resolved.route.endpoint.baseURL).toBe("https://proxy.example/v1")
})
})
+13 -5
View File
@@ -22,7 +22,7 @@ import { ProviderShared } from "../../src/protocols/shared"
import { Auth, LLMClient } from "../../src/route" import { Auth, LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client" import { compileRequest } from "../../src/route/client"
import { it } from "../lib/effect" import { it } from "../lib/effect"
import { dynamicResponse, fixedResponse, truncatedStream } from "../lib/http" import { dynamicResponse, fixedResponse, systemError, truncatedStream } from "../lib/http"
import { deltaChunk, usageChunk } from "../lib/openai-chunks" import { deltaChunk, usageChunk } from "../lib/openai-chunks"
import { sseEvents } from "../lib/sse" import { sseEvents } from "../lib/sse"
@@ -1221,12 +1221,20 @@ describe("OpenAI Chat route", () => {
it.effect("surfaces transport errors that occur mid-stream", () => it.effect("surfaces transport errors that occur mid-stream", () =>
Effect.gen(function* () { Effect.gen(function* () {
const layer = truncatedStream([ const layer = truncatedStream(
`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`, [`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`],
]) systemError("ECONNRESET", "socket closed unexpectedly"),
)
const error = yield* LLMClient.generate(request).pipe(Effect.provide(layer), Effect.flip) const error = yield* LLMClient.generate(request).pipe(Effect.provide(layer), Effect.flip)
expect(error.message).toContain("Failed to read openai/openai-chat stream") expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: socket closed unexpectedly",
transport: "http",
operation: "read",
code: "ECONNRESET",
url: "https://api.openai.test/v1/chat/completions",
})
}), }),
) )
@@ -243,6 +243,7 @@ describe("OpenAI Responses route", () => {
RequestExecutor.Service, RequestExecutor.Service,
RequestExecutor.Service.of({ RequestExecutor.Service.of({
execute: () => Effect.die("unexpected HTTP request"), execute: () => Effect.die("unexpected HTTP request"),
stream: () => Stream.die("unexpected HTTP request"),
}), }),
), ),
Layer.succeed( Layer.succeed(
@@ -69,6 +69,63 @@ describe("v2 session reducer", () => {
}) })
}) })
test("prefers durable selection predecessors and derives them for older events", () => {
const source: SessionMessageInfo[] = [
{ id: "msg_previous_agent", type: "agent-switched", agent: "build", time: { created: 1 } },
{
id: "msg_previous_model",
type: "model-switched",
model: { id: "old", providerID: "provider" },
time: { created: 1 },
},
]
const reducer = createV2SessionReducer()
const agent = reducer.reduce(
source,
event({
...base,
id: "evt_agent",
type: "session.agent.selected",
data: { sessionID: "ses_1", agent: "plan", previous: "review" },
}),
)
const model = reducer.reduce(
source,
event({
...base,
id: "evt_model",
type: "session.model.selected",
data: {
sessionID: "ses_1",
model: { id: "new", providerID: "provider" },
previous: { id: "durable", providerID: "provider" },
},
}),
)
const legacyAgent = reducer.reduce(
source,
event({
...base,
id: "evt_legacy_agent",
type: "session.agent.selected",
data: { sessionID: "ses_1", agent: "plan" },
}),
)
expect(agent?.messages.at(-1)).toMatchObject({ type: "agent-switched", agent: "plan", previous: "review" })
expect(model?.messages.at(-1)).toMatchObject({
type: "model-switched",
model: { id: "new" },
previous: { id: "durable" },
})
expect(legacyAgent?.messages.at(-1)).toMatchObject({
type: "agent-switched",
agent: "plan",
previous: "build",
})
})
test("folds tool, retry, and completion events", () => { test("folds tool, retry, and completion events", () => {
const reducer = createV2SessionReducer() const reducer = createV2SessionReducer()
let messages: SessionMessageInfo[] = [] let messages: SessionMessageInfo[] = []
@@ -61,6 +61,12 @@ export function createV2SessionReducer() {
type: "agent-switched", type: "agent-switched",
metadata: event.metadata, metadata: event.metadata,
agent: event.data.agent, agent: event.data.agent,
previous:
event.data.previous ??
source.findLast(
(item): item is Extract<SessionMessageInfo, { type: "agent-switched" | "assistant" }> =>
item.type === "agent-switched" || item.type === "assistant",
)?.agent,
time: { created: event.created }, time: { created: event.created },
}) })
case "session.model.selected": case "session.model.selected":
@@ -69,10 +75,12 @@ export function createV2SessionReducer() {
type: "model-switched", type: "model-switched",
metadata: event.metadata, metadata: event.metadata,
model: event.data.model, model: event.data.model,
previous: source.findLast( previous:
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> => event.data.previous ??
item.type === "model-switched" || item.type === "assistant", source.findLast(
)?.model, (item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
item.type === "model-switched" || item.type === "assistant",
)?.model,
time: { created: event.created }, time: { created: event.created },
}) })
case "session.synthetic": case "session.synthetic":
+2 -2
View File
@@ -351,8 +351,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const key = tabKey(tab) const key = tabKey(tab)
const next = { title: session.title, directory: session.location.directory } const next = { title: session.title, directory: session.location.directory }
const current = info[key] const current = info[key]
console.log({ tab, session, current }) if (current && current.title === next.title && current.directory === next.directory) return
if (current?.title === next.title && current.directory === next.directory) return console.debug("[tabs] update persisted session info", { key, sessionID: session.id, current, next })
setInfo(key, next) setInfo(key, next)
}, },
select: navigateTab, select: navigateTab,
@@ -4,6 +4,7 @@ import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection" import { ServerConnection } from "../../services/server-connection"
import { Config } from "../../config" import { Config } from "../../config"
import { resolve } from "@opencode-ai/tui/config" import { resolve } from "@opencode-ai/tui/config"
import { Global } from "@opencode-ai/util/global"
export default Runtime.handler(Commands.commands.mini, (input) => export default Runtime.handler(Commands.commands.mini, (input) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -16,6 +17,7 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
mismatch: "replace", mismatch: "replace",
}) })
const config = yield* Config.Service const config = yield* Config.Service
const global = yield* Global.Service
const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" }) const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" })
const fileSystem = yield* FileSystem.FileSystem const fileSystem = yield* FileSystem.FileSystem
const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem)) const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem))
@@ -39,6 +41,7 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
config: { config: {
update: (update) => runServicePromise(config.update(update)), update: (update) => runServicePromise(config.update(update)),
}, },
paths: { home: global.home, state: global.state, log: global.log },
}), }),
) )
}), }),
+2 -7
View File
@@ -1,6 +1,5 @@
import type { MiniFrontendInput } from "@opencode-ai/tui/mini" import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
import { createModelPreferenceRepository } from "@opencode-ai/tui/model-preference" import { createModelPreferenceRepository } from "@opencode-ai/tui/model-preference"
import { Global } from "@opencode-ai/util/global"
import fs from "node:fs" import fs from "node:fs"
import { readFile } from "node:fs/promises" import { readFile } from "node:fs/promises"
import path from "node:path" import path from "node:path"
@@ -129,13 +128,9 @@ export async function usingInteractiveStdin<T>(
export function createMiniHost(input: { export function createMiniHost(input: {
terminal: InteractiveStdin terminal: InteractiveStdin
directory: string directory: string
paths?: { home: string; state: string; log: string } paths: { home: string; state: string; log: string }
}): MiniHost { }): MiniHost {
const paths = input.paths ?? { const paths = input.paths
home: Global.Path.home,
state: Global.Path.state,
log: Global.Path.log,
}
const diagnostics = { const diagnostics = {
pid: process.pid, pid: process.pid,
cwd: input.directory, cwd: input.directory,
+2 -1
View File
@@ -22,6 +22,7 @@ export type MiniCommandInput = {
demo?: boolean demo?: boolean
tuiConfig?: MiniFrontendInput["tuiConfig"] tuiConfig?: MiniFrontendInput["tuiConfig"]
config?: MiniFrontendInput["config"] config?: MiniFrontendInput["config"]
paths: { home: string; state: string; log: string }
} }
type Model = MiniFrontendInput["model"] type Model = MiniFrontendInput["model"]
@@ -104,7 +105,7 @@ export async function runMini(input: MiniCommandInput) {
})) }))
const frontend = await frontendTask const frontend = await frontendTask
return frontend.runMiniFrontend({ return frontend.runMiniFrontend({
host: createMiniHost({ terminal, directory }), host: createMiniHost({ terminal, directory, paths: input.paths }),
sdk, sdk,
directory, directory,
target: resolveTarget, target: resolveTarget,
+2 -1
View File
@@ -39,7 +39,8 @@ export const run = Effect.fnUntraced(function* (options: Options) {
}) })
const processEffect = Effect.fnUntraced(function* (options: Options) { const processEffect = Effect.fnUntraced(function* (options: Options) {
if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home)) const global = yield* Global.Service
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
return yield* Effect.scoped( return yield* Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
+10 -2
View File
@@ -339,7 +339,11 @@ export type Endpoint5_31Output =
readonly type: "session.agent.selected" readonly type: "session.agent.selected"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly agent: Agent.ID } readonly data: {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly previous?: Agent.ID | undefined
}
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
@@ -348,7 +352,11 @@ export type Endpoint5_31Output =
readonly type: "session.model.selected" readonly type: "session.model.selected"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly model: Model.Ref } readonly data: {
readonly sessionID: Session.ID
readonly model: Model.Ref
readonly previous?: Model.Ref | undefined
}
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
@@ -41,6 +41,7 @@ export type SessionMessageAgentSelected = {
time: { created: number } time: { created: number }
type: "agent-switched" type: "agent-switched"
agent: string agent: string
previous?: string
} }
export type PromptBase64 = string export type PromptBase64 = string
@@ -435,7 +436,7 @@ export type SessionAgentSelected = {
type: "session.agent.selected" type: "session.agent.selected"
durable: { aggregateID: string; seq: number; version: 1 } durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef location?: LocationRef
data: { sessionID: string; agent: string } data: { sessionID: string; agent: string; previous?: string }
} }
export type SessionModelSelected = { export type SessionModelSelected = {
@@ -445,7 +446,7 @@ export type SessionModelSelected = {
type: "session.model.selected" type: "session.model.selected"
durable: { aggregateID: string; seq: number; version: 1 } durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef location?: LocationRef
data: { sessionID: string; model: ModelRef } data: { sessionID: string; model: ModelRef; previous?: ModelRef }
} }
export type SessionMoved = { export type SessionMoved = {
@@ -2535,6 +2536,7 @@ export type SessionImportInput = {
readonly time: { readonly created: number } readonly time: { readonly created: number }
readonly type: "agent-switched" readonly type: "agent-switched"
readonly agent: string readonly agent: string
readonly previous?: string
} }
| { | {
readonly id: string readonly id: string
@@ -2786,6 +2788,7 @@ export type SessionImportInput = {
readonly time: { readonly created: number } readonly time: { readonly created: number }
readonly type: "agent-switched" readonly type: "agent-switched"
readonly agent: string readonly agent: string
readonly previous?: string
} }
| { | {
readonly id: string readonly id: string
@@ -3037,6 +3040,7 @@ export type SessionImportInput = {
readonly time: { readonly created: number } readonly time: { readonly created: number }
readonly type: "agent-switched" readonly type: "agent-switched"
readonly agent: string readonly agent: string
readonly previous?: string
} }
| { | {
readonly id: string readonly id: string
+4 -5
View File
@@ -194,11 +194,10 @@ async function formatTypescript(input: string) {
function renderRegistry(names: string[]) { function renderRegistry(names: string[]) {
return `import type { DatabaseMigration } from "./migration" return `import type { DatabaseMigration } from "./migration"
${names.map((name, index) => `import m${index.toString().padStart(2, "0")} from "./migration/${name}"`).join("\n")}
export const migrations: DatabaseMigration.Migration[] = ( export const migrations = [
await Promise.all([ ${names.map((_, index) => ` m${index.toString().padStart(2, "0")},`).join("\n")}
${names.map((name) => ` import("./migration/${name}"),`).join("\n")} ] satisfies DatabaseMigration.Migration[]
])
).map((module) => module.default)
` `
} }
+19 -2
View File
@@ -508,8 +508,23 @@ function toolOutput(result: ToolResultValue) {
case "text": case "text":
case "error": case "error":
return { type: "text" as const, value: messageValue(result.value) } return { type: "text" as const, value: messageValue(result.value) }
case "content":
return {
type: "content" as const,
value: result.value.map((item) => {
if (item.type === "text") return { type: "text" as const, text: item.text }
const data = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(item.uri)?.[1]
const image = item.mime.toLowerCase().startsWith("image/")
if (data !== undefined)
return image
? { type: "image-data" as const, data, mediaType: item.mime }
: { type: "file-data" as const, data, mediaType: item.mime, filename: item.name }
return image ? { type: "image-url" as const, url: item.uri } : { type: "file-url" as const, url: item.uri }
}),
}
case "json":
return { type: "json" as const, value: jsonValue(result.value) }
} }
return { type: "json" as const, value: jsonValue(result.value) }
} }
function tool(input: ToolDefinition): LanguageModelV3FunctionTool { function tool(input: ToolDefinition): LanguageModelV3FunctionTool {
@@ -748,7 +763,9 @@ function apiCallErrorReason(error: APICallError) {
if (error.statusCode !== undefined || !error.isRetryable) return reason if (error.statusCode !== undefined || !error.isRetryable) return reason
return new TransportReason({ return new TransportReason({
message: reason.message, message: reason.message,
kind: error.name, transport: "http",
operation: "request",
code: error.name,
url: error.url, url: error.url,
http: "http" in reason ? reason.http : undefined, http: "http" in reason ? reason.http : undefined,
}) })
+11 -2
View File
@@ -11,6 +11,7 @@ import { ChildProcess } from "effect/unstable/process"
import { Config } from "./config" import { Config } from "./config"
import { Location } from "./location" import { Location } from "./location"
import { ShellSelect } from "./shell/select" import { ShellSelect } from "./shell/select"
import { Global } from "@opencode-ai/util/global"
export const Info = Command.Info export const Info = Command.Info
export type Info = Command.Info export type Info = Command.Info
@@ -61,6 +62,7 @@ export const layer = (options?: ShellSelect.Options) =>
const processes = yield* AppProcess.Service const processes = yield* AppProcess.Service
const config = yield* Config.Service const config = yield* Config.Service
const location = yield* Location.Service const location = yield* Location.Service
const global = yield* Global.Service
const state = State.create<Data, Draft>({ const state = State.create<Data, Draft>({
name: "command", name: "command",
initial: () => ({ commands: new Map() }), initial: () => ({ commands: new Map() }),
@@ -111,6 +113,7 @@ export const layer = (options?: ShellSelect.Options) =>
location, location,
processes, processes,
shell: options, shell: options,
bin: global.bin,
}) })
const prompt = (yield* mcp.prompts()).find( const prompt = (yield* mcp.prompts()).find(
@@ -164,6 +167,7 @@ function evaluateTemplate(
readonly location: Location.Info readonly location: Location.Info
readonly processes: AppProcess.Interface readonly processes: AppProcess.Interface
readonly shell?: ShellSelect.Options readonly shell?: ShellSelect.Options
readonly bin: string
}, },
) { ) {
return Effect.gen(function* () { return Effect.gen(function* () {
@@ -197,11 +201,16 @@ const evaluateShell = Effect.fnUntraced(function* (
readonly location: Location.Info readonly location: Location.Info
readonly processes: AppProcess.Interface readonly processes: AppProcess.Interface
readonly shell?: ShellSelect.Options readonly shell?: ShellSelect.Options
readonly bin: string
}, },
) { ) {
const matches = Array.from(text.matchAll(shellRegex)) const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text if (matches.length === 0) return text
const shell = ShellSelect.preferred(Config.latest(yield* services.config.entries(), "shell"), services.shell) const shell = ShellSelect.preferred(
Config.latest(yield* services.config.entries(), "shell"),
services.shell,
services.bin,
)
const outputs = yield* Effect.forEach( const outputs = yield* Effect.forEach(
matches, matches,
(match) => { (match) => {
@@ -262,7 +271,7 @@ export function configured(options?: ShellSelect.Options) {
return makeLocationNode({ return makeLocationNode({
service: Service, service: Service,
layer: layer(options), layer: layer(options),
deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node], deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node, Global.node],
}) })
} }
@@ -0,0 +1,122 @@
export * as ConfigInstructionPlugin from "./instruction"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { dirname, join } from "path"
import { Effect, PubSub, Semaphore, Stream } from "effect"
import { Watcher } from "../../filesystem/watcher"
import { InstructionDiscovery } from "../../instruction-discovery"
import { Instructions } from "../../instructions/index"
import { Location } from "../../location"
import { AbsolutePath } from "../../schema"
type Loaded =
| { readonly type: "available"; readonly files: InstructionDiscovery.File[] }
| { readonly type: "unavailable" }
export const Plugin = define({
id: "opencode.config.instruction",
effect: Effect.fn(function* () {
const discovery = yield* InstructionDiscovery.Service
yield* Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const watcher = yield* Watcher.Service
const changes = yield* PubSub.sliding<string>(1)
const lock = Semaphore.makeUnsafe(1)
const start = yield* fs.resolve(location.directory)
const stop = yield* fs.resolve(location.project.directory)
const project = discovery.project && FSUtil.contains(stop, start)
const globalFile = yield* fs.resolve(join(global.config, "AGENTS.md"))
const loaded: { current: Loaded } = { current: { type: "available", files: [] } }
const publish = (update: Watcher.Update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid)
const candidates = [
globalFile,
...(project ? ancestorDirectories(start, stop).map((directory) => join(directory, "AGENTS.md")) : []),
]
for (const path of new Set(candidates)) {
const updates = yield* watcher.subscribe({ path, type: "file" })
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
}
const read = Effect.fn("ConfigInstructionPlugin.read")(function* (path: string) {
const content = yield* fs.readFileStringSafe(path)
if (content !== undefined) return new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
yield* Effect.logDebug("instruction file skipped", { path, reason: "unavailable" })
})
const globalSource = Effect.fn("ConfigInstructionPlugin.globalSource")(function* () {
const file = yield* read(globalFile)
return file ? [file] : []
})
const projectSource = Effect.fn("ConfigInstructionPlugin.projectSource")(function* () {
if (!project) return []
const discovered = new Set(
yield* Effect.forEach(yield* fs.up({ targets: ["AGENTS.md"], start, stop }), fs.resolve),
)
const files = yield* Effect.forEach(discovered, read, { concurrency: "unbounded" })
if (files.some((file) => file === undefined)) return Instructions.unavailable
return files.filter((file): file is InstructionDiscovery.File => file !== undefined)
})
const isolate = <A, E, R>(source: string, effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load instruction source", { source, cause }).pipe(
Effect.as(Instructions.unavailable),
),
),
)
const refresh = Effect.fn("ConfigInstructionPlugin.refresh")(function* (file?: string) {
yield* lock.withPermit(
Effect.gen(function* () {
const sources = yield* Effect.all({
global: isolate("global", globalSource()),
project: isolate("project", projectSource()),
})
loaded.current =
Array.isArray(sources.global) && Array.isArray(sources.project)
? { type: "available", files: [...sources.global, ...sources.project] }
: { type: "unavailable" }
if (!file) return
yield* Effect.logDebug("instructions rescanned", {
file,
instructions:
loaded.current.type === "available" ? loaded.current.files.map((item) => item.path) : "unavailable",
})
}),
)
})
yield* Stream.fromPubSub(changes).pipe(
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(discovery.reload()))),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh()
yield* discovery.transform((draft) => {
if (loaded.current.type === "unavailable") {
draft.unavailable()
return
}
for (const file of loaded.current.files) draft.add(file)
})
}).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to activate instruction source", { cause }).pipe(
Effect.andThen(discovery.transform((draft) => draft.unavailable())),
Effect.asVoid,
),
),
)
}),
})
function ancestorDirectories(start: string, stop: string): string[] {
if (start === stop) return [start]
return [start, ...ancestorDirectories(dirname(start), stop)]
}
@@ -0,0 +1,57 @@
export * as SkillFile from "./skill-file"
import path from "path"
import { Result, Schema, type SchemaIssue, SchemaParser } from "effect"
import { ConfigMarkdown } from "../markdown"
import { AbsolutePath } from "../../schema"
import { Skill } from "../../skill"
const Frontmatter = Schema.Struct({
name: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
slash: Schema.Boolean.pipe(Schema.optional),
metadata: Schema.Unknown.pipe(Schema.optional),
})
const decodeFrontmatter = SchemaParser.decodeUnknownResult(Frontmatter)
export type ParseResult =
| { readonly _tag: "Parsed"; readonly skill: Skill.Info }
| { readonly _tag: "Skipped"; readonly reason: "markdown" }
| { readonly _tag: "Skipped"; readonly reason: "frontmatter"; readonly issue: SchemaIssue.Issue }
const metadataBoolean = (metadata: unknown, key: string) => {
if (metadata === undefined || metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
return undefined
}
const value = Reflect.get(metadata, key)
if (typeof value === "boolean") return value
if (typeof value !== "string") return undefined
const normalized = value.trim().toLowerCase()
if (normalized === "true") return true
if (normalized === "false") return false
return undefined
}
export function parse(directory: string, filepath: string, content: string): ParseResult {
const markdown = ConfigMarkdown.parseOption(content)
if (!markdown) return { _tag: "Skipped", reason: "markdown" }
const decoded = decodeFrontmatter(markdown.data)
if (Result.isFailure(decoded)) return { _tag: "Skipped", reason: "frontmatter", issue: decoded.failure }
const frontmatter = decoded.success
const id =
path.dirname(filepath) === directory ? path.basename(filepath, ".md") : path.basename(path.dirname(filepath))
const slash = metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash
const autoinvoke = metadataBoolean(frontmatter.metadata, "opencode/autoinvoke")
return {
_tag: "Parsed",
skill: {
id: Skill.ID.make(id),
name: Skill.Name.make(frontmatter.name ?? id),
...(frontmatter.description === undefined ? {} : { description: frontmatter.description }),
...(slash === undefined ? {} : { slash }),
...(autoinvoke === undefined ? {} : { autoinvoke }),
location: AbsolutePath.make(filepath),
content: markdown.content,
},
}
}
+149 -22
View File
@@ -1,64 +1,191 @@
export * as ConfigSkillPlugin from "./skill" export * as ConfigSkillPlugin from "./skill"
import { define } from "@opencode-ai/plugin/effect/plugin" import { define } from "@opencode-ai/plugin/effect/plugin"
import type { Entry } from "@opencode-ai/schema/config"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import path from "path" import path from "path"
import { Effect, Stream } from "effect" import { Effect, FiberMap, PubSub, Semaphore, Stream } from "effect"
import { Config } from "../../config" import { Config } from "../../config"
import { Watcher } from "../../filesystem/watcher"
import { Location } from "../../location"
import { AbsolutePath } from "../../schema" import { AbsolutePath } from "../../schema"
import { Skill } from "../../skill" import { Skill } from "../../skill"
import { Global } from "@opencode-ai/util/global" import { SkillDiscovery } from "../../skill/discovery"
import { Location } from "../../location" import { SkillFile } from "./skill-file"
type Source = Skill.DirectorySource | Skill.UrlSource
export const Plugin = define({ export const Plugin = define({
id: "opencode.config.skill", id: "opencode.config.skill",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service const config = yield* Config.Service
const discovery = yield* SkillDiscovery.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service const global = yield* Global.Service
const location = yield* Location.Service const location = yield* Location.Service
const loaded = { entries: yield* config.entries() } const watcher = yield* Watcher.Service
yield* ctx.skill.transform((draft) => { const loaded: { entries: Entry[]; skills: Skill.Info[] } = {
entries: yield* config.entries(),
skills: [],
}
const watches = yield* FiberMap.make<string>()
const changes = yield* PubSub.sliding<string>(1)
const lock = Semaphore.makeUnsafe(1)
const watch = Effect.fn("ConfigSkillPlugin.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
const target = path.resolve(directory)
const updates = yield* watcher.subscribe({ path: target, type })
yield* FiberMap.run(
watches,
`${type}:${target}`,
updates.pipe(Stream.runForEach((update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid))),
{ onlyIfMissing: true, startImmediately: true },
)
})
function firstMissing(target: string): Effect.Effect<string | undefined> {
const parent = path.dirname(target)
if (parent === target) return Effect.succeed(undefined)
return fs.isDir(parent).pipe(Effect.flatMap((exists) => (exists ? Effect.succeed(target) : firstMissing(parent))))
}
const watchDirectory: (directory: string) => Effect.Effect<string[]> = Effect.fn(
"ConfigSkillPlugin.watchDirectory",
)(function* (directory: string) {
const target = path.resolve(directory)
const resolved = yield* fs.realPath(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (resolved) {
yield* watch(resolved, "directory")
if (resolved !== target) yield* watch(target, "file")
return resolved === target ? [target] : [target, resolved]
}
const missing = yield* firstMissing(target)
if (missing) yield* watch(missing, "file")
if (
yield* fs.realPath(directory).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
) {
if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`)
return yield* watchDirectory(directory)
}
return [target]
})
const sources = () => {
const result: Source[] = []
const add = (source: Source) => {
if (result.some((item) => Skill.Source.equals(item, source))) return
result.push(source)
}
const claude = loaded.entries.flatMap((entry) => (entry.type === "claude" ? [entry.path] : [])) const claude = loaded.entries.flatMap((entry) => (entry.type === "claude" ? [entry.path] : []))
const agents = loaded.entries.flatMap((entry) => (entry.type === "agents" ? [entry.path] : [])) const agents = loaded.entries.flatMap((entry) => (entry.type === "agents" ? [entry.path] : []))
const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
for (const directory of [...claude, ...agents]) { for (const directory of [...claude, ...agents]) {
draft.source( add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }))
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join(directory, "skills")),
}),
)
} }
for (const directory of directories) { for (const directory of directories) {
draft.source( add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }))
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }), add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }))
)
draft.source(
Skill.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join(directory, "skills")),
}),
)
} }
for (const item of items) { for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) { if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
draft.source(Skill.UrlSource.make({ type: "url", url: item })) add(Skill.UrlSource.make({ type: "url", url: item }))
continue continue
} }
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
draft.source( add(
Skill.DirectorySource.make({ Skill.DirectorySource.make({
type: "directory", type: "directory",
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)), path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
}), }),
) )
} }
return result
}
const load = Effect.fn("ConfigSkillPlugin.load")(function* (source: Source) {
const directories =
source.type === "directory"
? [source.path]
: yield* discovery.pull(source.url).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load skill source", {
source: Skill.Source.key(source),
cause,
}).pipe(Effect.as([] as AbsolutePath[])),
),
)
const roots = (yield* Effect.forEach(directories, watchDirectory)).flat()
const skills: Skill.Info[] = []
for (const directory of directories) {
const files = yield* fs
.scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
for (const filepath of files.toSorted()) {
const resolved = yield* fs.realPath(filepath).pipe(Effect.catch(() => Effect.succeed(filepath)))
if (!roots.some((root) => FSUtil.contains(root, resolved))) yield* watch(path.dirname(resolved), "directory")
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!content) continue
const parsed = SkillFile.parse(directory, filepath, content)
if (parsed._tag === "Skipped") {
yield* Effect.logDebug("skill file skipped", {
filepath,
reason: parsed.reason,
...(parsed.reason === "frontmatter" ? { issue: parsed.issue } : {}),
})
continue
}
skills.push(parsed.skill)
}
}
yield* Effect.logDebug("skill source loaded", {
source: Skill.Source.key(source),
type: source.type,
directories,
skills: skills.map((skill) => skill.id),
})
return skills
})
const refresh = Effect.fn("ConfigSkillPlugin.refresh")(function* (file?: string) {
yield* lock.withPermit(
Effect.gen(function* () {
yield* FiberMap.clear(watches)
const skills = new Map<Skill.ID, Skill.Info>()
const current = sources()
for (const source of current) {
for (const skill of yield* load(source)) skills.set(skill.id, skill)
}
loaded.skills = Array.from(skills.values())
if (file) {
yield* Effect.logInfo("skills rescanned", {
file,
sources: current.map(Skill.Source.key),
skills: loaded.skills.map((skill) => skill.id),
})
}
}),
)
})
yield* Stream.fromPubSub(changes).pipe(
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(ctx.skill.reload()))),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh()
yield* ctx.skill.transform((draft) => {
for (const skill of loaded.skills) draft.add(skill)
}) })
yield* ctx.event.subscribe().pipe( yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"), Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => Stream.runForEach(() =>
config.entries().pipe( config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))), Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(refresh()),
Effect.andThen(ctx.skill.reload()), Effect.andThen(ctx.skill.reload()),
), ),
), ),
+179
View File
@@ -0,0 +1,179 @@
export * as ConfigPluginSource from "./source"
import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
import path from "path"
import { fileURLToPath } from "url"
import { Config } from "../../config"
import { Watcher } from "../../filesystem/watcher"
import { Location } from "../../location"
export type Operation =
| {
readonly type: "add"
readonly target: string
readonly options: Record<string, unknown>
readonly mtime?: number
}
| {
readonly type: "remove"
readonly target: string
}
export interface Interface {
readonly operations: () => Effect.Effect<readonly Operation[], never, Scope.Scope>
readonly changes: () => Stream.Stream<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ConfigPluginSource") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const watcher = yield* Watcher.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const configuredChanges = yield* PubSub.unbounded<void>()
const watched = new Set<string>()
// Configured local plugin files can live outside config roots, where the
// config change feed cannot see them; watch those entrypoints directly.
// Watches start on first sighting and are never torn down individually:
// a stale watch after a config edit costs one deduped fs handle and a
// no-op activation, and every watch dies with this layer's scope.
const watchConfiguredSources = Effect.fn("ConfigPluginSource.watchConfiguredSources")(function* (
entries: readonly Entry[],
operations: readonly Operation[],
) {
for (const operation of operations) {
if (operation.type !== "add" || !path.isAbsolute(operation.target)) continue
if (watched.has(operation.target)) continue
// The config change feed already covers {plugin,plugins} directories.
if (isPluginSource(entries, operation.target)) continue
// Directory targets can't hot-reload (their stat mtime ignores edits
// inside), so don't watch what can't trigger anything.
if (yield* fs.isDir(operation.target)) continue
watched.add(operation.target)
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
yield* updates.pipe(
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
Effect.catchCause((cause) =>
Effect.logError("configured plugin watch failed", { target: operation.target, cause }),
),
Effect.forkScoped({ startImmediately: true }),
)
}
})
return Service.of({
operations: Effect.fn("ConfigPluginSource.operations")(function* () {
const entries = yield* config.entries()
const operations = yield* scan(fs, location, entries)
yield* watchConfiguredSources(entries, operations)
return operations
}),
changes: () =>
Stream.merge(
config.changes().pipe(
Stream.filterEffect((update) =>
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
),
Stream.map(() => undefined),
),
Stream.fromPubSub(configuredChanges),
),
})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Config.node, FSUtil.node, Watcher.node, Location.node],
})
export const empty = makeLocationNode({
service: Service,
layer: Layer.succeed(
Service,
Service.of({
operations: () => Effect.succeed([]),
changes: () => Stream.never,
}),
),
deps: [],
})
function parse(input: ConfigPlugin.Plugin): Operation {
if (typeof input !== "string") {
return { type: "add", target: input.package, options: input.options ?? {} }
}
if (!input.startsWith("-")) return { type: "add", target: input, options: {} }
if (input.length === 1) throw new Error("Plugin remove operation requires a target")
return { type: "remove", target: input.slice(1) }
}
const scan = Effect.fn("ConfigPluginSource.scan")(function* (
fs: FSUtil.Interface,
location: Location.Interface,
entries: readonly Entry[],
) {
const discovered = yield* Effect.forEach(
entries.filter((entry): entry is Directory => entry.type === "directory"),
(entry) => discoverDirectory(fs, entry.path),
).pipe(Effect.map((items) => items.flat()))
const configured = entries
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) =>
(entry.info.plugins ?? []).map(parse).map((operation) => {
if (operation.type === "remove") return operation
const directory = entry.path ? path.dirname(entry.path) : location.directory
const target = operation.target.startsWith("file://")
? fileURLToPath(operation.target)
: operation.target.startsWith("./") || operation.target.startsWith("../")
? path.resolve(directory, operation.target)
: operation.target
return { ...operation, target }
}),
)
// Explicit config is applied last so it can remove auto-discovered packages.
return yield* Effect.forEach([...discovered, ...configured], (operation) => {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
return fs.stat(operation.target).pipe(
Effect.map((info) => ({
...operation,
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
})),
Effect.catch(() => Effect.succeed(operation)),
)
})
})
const sourceDirectories = ["plugin", "plugins"] as const
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const files = yield* fs
.scan(`{${sourceDirectories.join(",")}}/*.{ts,js}`, {
cwd: directory,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
return files.sort().map((target): Operation => ({ type: "add", target, options: {} }))
})
}
function isPluginSource(entries: readonly Entry[], file: string) {
return entries.some(
(entry) =>
entry.type === "directory" &&
sourceDirectories.some((directory) => FSUtil.contains(path.join(entry.path, directory), file)),
)
}
+10 -7
View File
@@ -40,16 +40,19 @@ const databaseLayer = Layer.effect(
) )
export function layer(options: Options = { path: ":memory:" }) { export function layer(options: Options = { path: ":memory:" }) {
return Layer.suspend(() => { return Layer.unwrap(
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename }))) Effect.gen(function* () {
const filename = options.path ?? ":memory:" const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename) const filename = options.path ?? ":memory:"
return provide(join(Global.Path.data, filename)) if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
}) const global = yield* Global.Service
return provide(join(global.data, filename))
}),
)
} }
export function configured(options?: Options) { export function configured(options?: Options) {
return makeGlobalNode({ service: Service, layer: layer(options), deps: [] }) return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] })
} }
export const node = configured({ path: ":memory:" }) export const node = configured({ path: ":memory:" })
+84 -45
View File
@@ -1,47 +1,86 @@
import type { DatabaseMigration } from "./migration" import type { DatabaseMigration } from "./migration"
import m00 from "./migration/20260127222353_familiar_lady_ursula"
import m01 from "./migration/20260211171708_add_project_commands"
import m02 from "./migration/20260213144116_wakeful_the_professor"
import m03 from "./migration/20260225215848_workspace"
import m04 from "./migration/20260227213759_add_session_workspace_id"
import m05 from "./migration/20260228203230_blue_harpoon"
import m06 from "./migration/20260303231226_add_workspace_fields"
import m07 from "./migration/20260309230000_move_org_to_state"
import m08 from "./migration/20260312043431_session_message_cursor"
import m09 from "./migration/20260323234822_events"
import m10 from "./migration/20260410174513_workspace-name"
import m11 from "./migration/20260413175956_chief_energizer"
import m12 from "./migration/20260423070820_add_icon_url_override"
import m13 from "./migration/20260427172553_slow_nightmare"
import m14 from "./migration/20260428004200_add_session_path"
import m15 from "./migration/20260501142318_next_venus"
import m16 from "./migration/20260504145000_add_sync_owner"
import m17 from "./migration/20260507164347_add_workspace_time"
import m18 from "./migration/20260510033149_session_usage"
import m19 from "./migration/20260511000411_data_migration_state"
import m20 from "./migration/20260511173437_session-metadata"
import m21 from "./migration/20260601010001_normalize_storage_paths"
import m22 from "./migration/20260601202201_amazing_prowler"
import m23 from "./migration/20260602002951_lowly_union_jack"
import m24 from "./migration/20260602182828_add_project_directories"
import m25 from "./migration/20260603001617_session_message_projection_indexes"
import m26 from "./migration/20260603040000_session_message_projection_order"
import m27 from "./migration/20260603141458_session_input_inbox"
import m28 from "./migration/20260603160727_jittery_ezekiel_stane"
import m29 from "./migration/20260604172448_event_sourced_session_input"
import m30 from "./migration/20260605003541_add_session_context_snapshot"
import m31 from "./migration/20260605042240_add_context_epoch_agent"
import m32 from "./migration/20260611035744_credential"
import m33 from "./migration/20260611192811_lush_chimera"
import m34 from "./migration/20260612174303_project_dir_strategy"
import m35 from "./migration/20260622142730_simplify_session_context_epoch"
import m36 from "./migration/20260622170816_reset_v2_session_state"
import m37 from "./migration/20260622202450_simplify_session_input"
import m38 from "./migration/20260804233008_loose_psylocke"
import m39 from "./migration/20260805200742_import_legacy_credentials"
import m40 from "./migration/20260808023530_workspace_domain"
export const migrations: DatabaseMigration.Migration[] = ( export const migrations = [
await Promise.all([ m00,
import("./migration/20260127222353_familiar_lady_ursula"), m01,
import("./migration/20260211171708_add_project_commands"), m02,
import("./migration/20260213144116_wakeful_the_professor"), m03,
import("./migration/20260225215848_workspace"), m04,
import("./migration/20260227213759_add_session_workspace_id"), m05,
import("./migration/20260228203230_blue_harpoon"), m06,
import("./migration/20260303231226_add_workspace_fields"), m07,
import("./migration/20260309230000_move_org_to_state"), m08,
import("./migration/20260312043431_session_message_cursor"), m09,
import("./migration/20260323234822_events"), m10,
import("./migration/20260410174513_workspace-name"), m11,
import("./migration/20260413175956_chief_energizer"), m12,
import("./migration/20260423070820_add_icon_url_override"), m13,
import("./migration/20260427172553_slow_nightmare"), m14,
import("./migration/20260428004200_add_session_path"), m15,
import("./migration/20260501142318_next_venus"), m16,
import("./migration/20260504145000_add_sync_owner"), m17,
import("./migration/20260507164347_add_workspace_time"), m18,
import("./migration/20260510033149_session_usage"), m19,
import("./migration/20260511000411_data_migration_state"), m20,
import("./migration/20260511173437_session-metadata"), m21,
import("./migration/20260601010001_normalize_storage_paths"), m22,
import("./migration/20260601202201_amazing_prowler"), m23,
import("./migration/20260602002951_lowly_union_jack"), m24,
import("./migration/20260602182828_add_project_directories"), m25,
import("./migration/20260603001617_session_message_projection_indexes"), m26,
import("./migration/20260603040000_session_message_projection_order"), m27,
import("./migration/20260603141458_session_input_inbox"), m28,
import("./migration/20260603160727_jittery_ezekiel_stane"), m29,
import("./migration/20260604172448_event_sourced_session_input"), m30,
import("./migration/20260605003541_add_session_context_snapshot"), m31,
import("./migration/20260605042240_add_context_epoch_agent"), m32,
import("./migration/20260611035744_credential"), m33,
import("./migration/20260611192811_lush_chimera"), m34,
import("./migration/20260612174303_project_dir_strategy"), m35,
import("./migration/20260622142730_simplify_session_context_epoch"), m36,
import("./migration/20260622170816_reset_v2_session_state"), m37,
import("./migration/20260622202450_simplify_session_input"), m38,
import("./migration/20260804233008_loose_psylocke"), m39,
import("./migration/20260805200742_import_legacy_credentials"), m40,
import("./migration/20260808023530_workspace_domain"), ] satisfies DatabaseMigration.Migration[]
])
).map((module) => module.default)
+2 -1
View File
@@ -5,6 +5,7 @@ import { Effect, Semaphore } from "effect"
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { migrations } from "./migration.gen" import { migrations } from "./migration.gen"
import schema from "./schema.gen" import schema from "./schema.gen"
import { Global } from "@opencode-ai/util/global"
type Database = EffectDrizzleSqlite.EffectSQLiteDatabase type Database = EffectDrizzleSqlite.EffectSQLiteDatabase
type Transaction = Parameters<Parameters<Database["transaction"]>[0]>[0] type Transaction = Parameters<Parameters<Database["transaction"]>[0]>[0]
@@ -13,7 +14,7 @@ const lock = Semaphore.makeUnsafe(1)
export type Migration = { export type Migration = {
id: string id: string
foreignKeys?: boolean foreignKeys?: boolean
up: (tx: Transaction) => Effect.Effect<void, unknown> up: (tx: Transaction) => Effect.Effect<void, unknown, Global.Service>
} }
export function apply(db: Database) { export function apply(db: Database) {
@@ -1,3 +1,4 @@
import { readFile } from "node:fs/promises"
import path from "node:path" import path from "node:path"
import { sql } from "drizzle-orm" import { sql } from "drizzle-orm"
import { Effect, Option, Schema } from "effect" import { Effect, Option, Schema } from "effect"
@@ -33,7 +34,10 @@ const wellKnownSourcesKey = "wellknown:sources"
const migration: DatabaseMigration.Migration = { const migration: DatabaseMigration.Migration = {
id: "20260805200742_import_legacy_credentials", id: "20260805200742_import_legacy_credentials",
up(tx) { up(tx) {
return importLegacyCredentials(tx, path.join(Global.Path.data, "auth.json")) return Effect.gen(function* () {
const global = yield* Global.Service
return yield* importLegacyCredentials(tx, path.join(global.data, "auth.json"))
})
}, },
} }
@@ -41,9 +45,9 @@ export default migration
export function importLegacyCredentials(tx: Parameters<DatabaseMigration.Migration["up"]>[0], filepath: string) { export function importLegacyCredentials(tx: Parameters<DatabaseMigration.Migration["up"]>[0], filepath: string) {
return Effect.gen(function* () { return Effect.gen(function* () {
const file = Bun.file(filepath) const content = yield* Effect.promise(() => readFile(filepath, "utf8").catch(() => undefined))
if (!(yield* Effect.promise(() => file.exists()))) return if (content === undefined) return
const input = Option.getOrUndefined(decodeJson(yield* Effect.promise(() => file.text()))) const input = Option.getOrUndefined(decodeJson(content))
if (typeof input !== "object" || input === null || Array.isArray(input)) { if (typeof input !== "object" || input === null || Array.isArray(input)) {
return yield* Effect.fail(new Error("Legacy credential file must contain an object")) return yield* Effect.fail(new Error("Legacy credential file must contain an object"))
} }
+18 -8
View File
@@ -7,7 +7,7 @@ import { SessionV1 } from "@opencode-ai/schema/session-v1"
import { SessionMessage } from "../session/message" import { SessionMessage } from "../session/message"
import { SessionSchema } from "../session/schema" import { SessionSchema } from "../session/schema"
import { KVTable } from "../kv/sql" import { KVTable } from "../kv/sql"
import { EventSequenceTable, EventTable } from "../event/sql" import { EventSequenceTable } from "../event/sql"
import { eq, sql } from "drizzle-orm" import { eq, sql } from "drizzle-orm"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { existsSync } from "node:fs" import { existsSync } from "node:fs"
@@ -161,6 +161,7 @@ type NextMessage = {
const lock = Semaphore.makeUnsafe(1) const lock = Semaphore.makeUnsafe(1)
const MIGRATION_STATE_KEY = "migration.v1-v2" const MIGRATION_STATE_KEY = "migration.v1-v2"
const EVENT_DELETE_BATCH_SIZE = 1_000
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const decodeMessage = Schema.decodeUnknownOption(SessionV1.Info) const decodeMessage = Schema.decodeUnknownOption(SessionV1.Info)
const decodePart = Schema.decodeUnknownOption(SessionV1.Part) const decodePart = Schema.decodeUnknownOption(SessionV1.Part)
@@ -467,10 +468,11 @@ function updateProgress(progress: Progress) {
if (runtimeState.status === "running") runtimeState = { status: "running", progress } if (runtimeState.status === "running") runtimeState = { status: "running", progress }
} }
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service> { export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
return lock.withPermit( return lock.withPermit(
Effect.gen(function* () { Effect.gen(function* () {
const { db } = yield* Database.Service const { db } = yield* Database.Service
const global = yield* Global.Service
const state = yield* readState(db) const state = yield* readState(db)
if (state?.phase === "completed") return { status: "completed" as const } if (state?.phase === "completed") return { status: "completed" as const }
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const } if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
@@ -478,13 +480,21 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
const now = Date.now() const now = Date.now()
yield* db.run(sql` yield* db.run(sql`
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes) INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
VALUES (${Project.ID.global}, ${path.parse(Global.Path.data).root}, ${now}, ${now}, '[]') VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
`) `)
if (state === undefined) if (state === undefined)
yield* db yield* db
.transaction((tx) => .transaction((tx) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* tx.delete(EventTable).run() while (true) {
yield* tx.run(sql`
DELETE FROM event
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
`)
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx yield* tx
.insert(KVTable) .insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } }) .values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
@@ -492,7 +502,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
}), }),
) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options)) const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0 const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
const cursor = state?.phase === "sessions" ? state.cursor : undefined const cursor = state?.phase === "sessions" ? state.cursor : undefined
const migrated = const migrated =
@@ -502,7 +512,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
: 0 : 0
const denominator = sourceTotal + legacyTotal const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator }) updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options), (completed) => { yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator }) updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
}) })
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator }) updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
@@ -621,10 +631,10 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
) )
} }
function nextPath(options: Options) { function nextPath(options: Options, data: string) {
if (options.nextDatabasePath) return options.nextDatabasePath if (options.nextDatabasePath) return options.nextDatabasePath
if (process.env.OPENCODE_DB === ":memory:") return undefined if (process.env.OPENCODE_DB === ":memory:") return undefined
return path.join(Global.Path.data, "opencode-next.db") return path.join(data, "opencode-next.db")
} }
function openNextDatabase(sourcePath: string) { function openNextDatabase(sourcePath: string) {
+4 -1
View File
@@ -7,6 +7,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util" import { FSUtil } from "@opencode-ai/util/fs-util"
import { Npm } from "@opencode-ai/util/npm" import { Npm } from "@opencode-ai/util/npm"
import { AppProcess } from "@opencode-ai/util/process" import { AppProcess } from "@opencode-ai/util/process"
import { Global } from "@opencode-ai/util/global"
import { Config } from "./config" import { Config } from "./config"
import { Location } from "./location" import { Location } from "./location"
import { make, type Info } from "./formatter/builtins" import { make, type Info } from "./formatter/builtins"
@@ -25,6 +26,7 @@ const layer = Layer.effect(
const location = yield* Location.Service const location = yield* Location.Service
const npm = yield* Npm.Service const npm = yield* Npm.Service
const processes = yield* AppProcess.Service const processes = yield* AppProcess.Service
const global = yield* Global.Service
const commands = new Map<string, string[] | false>() const commands = new Map<string, string[] | false>()
let formatters: Info[] = [] let formatters: Info[] = []
@@ -42,6 +44,7 @@ const layer = Layer.effect(
fs, fs,
npm, npm,
processes, processes,
bin: global.bin,
}) })
formatters = builtIns formatters = builtIns
if (configured === true) return if (configured === true) return
@@ -122,5 +125,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({ export const node = makeLocationNode({
service: Service, service: Service,
layer, layer,
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node], deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node],
}) })
+34 -26
View File
@@ -18,8 +18,10 @@ export function make(input: {
readonly fs: FSUtil.Interface readonly fs: FSUtil.Interface
readonly npm: Npm.Interface readonly npm: Npm.Interface
readonly processes: AppProcess.Interface readonly processes: AppProcess.Interface
readonly bin: string
}) { }) {
const disabled = false as const const disabled = false as const
const findExecutable = (name: string) => which(name, undefined, input.bin)
const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree) const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree)
const readText = (file: string) => input.fs.readFileString(file).pipe(Effect.orElseSucceed(() => "")) const readText = (file: string) => input.fs.readFileString(file).pipe(Effect.orElseSucceed(() => ""))
const commandOutput = (command: string[]) => const commandOutput = (command: string[]) =>
@@ -37,7 +39,7 @@ export function make(input: {
name: "gofmt", name: "gofmt",
extensions: [".go"], extensions: [".go"],
enabled: Effect.sync(() => { enabled: Effect.sync(() => {
const match = which("gofmt") const match = findExecutable("gofmt")
return match ? [match, "-w", "$FILE"] : disabled return match ? [match, "-w", "$FILE"] : disabled
}), }),
} }
@@ -46,7 +48,7 @@ export function make(input: {
name: "mix", name: "mix",
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"], extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
enabled: Effect.sync(() => { enabled: Effect.sync(() => {
const match = which("mix") const match = findExecutable("mix")
return match ? [match, "format", "$FILE"] : disabled return match ? [match, "format", "$FILE"] : disabled
}), }),
} }
@@ -149,7 +151,7 @@ export function make(input: {
name: "zig", name: "zig",
extensions: [".zig", ".zon"], extensions: [".zig", ".zon"],
enabled: Effect.sync(() => { enabled: Effect.sync(() => {
const match = which("zig") const match = findExecutable("zig")
return match ? [match, "fmt", "$FILE"] : disabled return match ? [match, "fmt", "$FILE"] : disabled
}), }),
} }
@@ -159,7 +161,7 @@ export function make(input: {
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"], extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
enabled: Effect.gen(function* () { enabled: Effect.gen(function* () {
if (!(yield* findUp(".clang-format")).length) return disabled if (!(yield* findUp(".clang-format")).length) return disabled
const match = which("clang-format") const match = findExecutable("clang-format")
return match ? [match, "-i", "$FILE"] : disabled return match ? [match, "-i", "$FILE"] : disabled
}).pipe(Effect.orElseSucceed(() => disabled)), }).pipe(Effect.orElseSucceed(() => disabled)),
} }
@@ -168,7 +170,7 @@ export function make(input: {
name: "ktlint", name: "ktlint",
extensions: [".kt", ".kts"], extensions: [".kt", ".kts"],
enabled: Effect.sync(() => { enabled: Effect.sync(() => {
const match = which("ktlint") const match = findExecutable("ktlint")
return match ? [match, "-F", "$FILE"] : disabled return match ? [match, "-F", "$FILE"] : disabled
}), }),
} }
@@ -177,17 +179,18 @@ export function make(input: {
name: "ruff", name: "ruff",
extensions: [".py", ".pyi"], extensions: [".py", ".pyi"],
enabled: Effect.gen(function* () { enabled: Effect.gen(function* () {
if (!which("ruff")) return disabled const bin = findExecutable("ruff")
if (!bin) return disabled
for (const config of ["pyproject.toml", "ruff.toml", ".ruff.toml"]) { for (const config of ["pyproject.toml", "ruff.toml", ".ruff.toml"]) {
const found = yield* findUp(config) const found = yield* findUp(config)
if (!found.length) continue if (!found.length) continue
if (config !== "pyproject.toml" || (yield* readText(found[0])).includes("[tool.ruff]")) { if (config !== "pyproject.toml" || (yield* readText(found[0])).includes("[tool.ruff]")) {
return ["ruff", "format", "$FILE"] return [bin, "format", "$FILE"]
} }
} }
for (const dependency of ["requirements.txt", "pyproject.toml", "Pipfile"]) { for (const dependency of ["requirements.txt", "pyproject.toml", "Pipfile"]) {
const found = yield* findUp(dependency) const found = yield* findUp(dependency)
if (found.length && (yield* readText(found[0])).includes("ruff")) return ["ruff", "format", "$FILE"] if (found.length && (yield* readText(found[0])).includes("ruff")) return [bin, "format", "$FILE"]
} }
return disabled return disabled
}).pipe(Effect.orElseSucceed(() => disabled)), }).pipe(Effect.orElseSucceed(() => disabled)),
@@ -197,7 +200,7 @@ export function make(input: {
name: "air", name: "air",
extensions: [".R"], extensions: [".R"],
enabled: Effect.gen(function* () { enabled: Effect.gen(function* () {
const bin = which("air") const bin = findExecutable("air")
if (!bin) return disabled if (!bin) return disabled
const output = yield* commandOutput([bin, "--help"]) const output = yield* commandOutput([bin, "--help"])
if (output._tag === "None" || output.value.exitCode !== 0) return disabled if (output._tag === "None" || output.value.exitCode !== 0) return disabled
@@ -210,34 +213,34 @@ export function make(input: {
name: "uv", name: "uv",
extensions: [".py", ".pyi"], extensions: [".py", ".pyi"],
enabled: Effect.gen(function* () { enabled: Effect.gen(function* () {
const bin = which("uv") const bin = findExecutable("uv")
if (!bin) return disabled if (!bin) return disabled
const output = yield* commandOutput([bin, "format", "--help"]) const output = yield* commandOutput([bin, "format", "--help"])
return output._tag === "Some" && output.value.exitCode === 0 ? [bin, "format", "--", "$FILE"] : disabled return output._tag === "Some" && output.value.exitCode === 0 ? [bin, "format", "--", "$FILE"] : disabled
}), }),
} }
const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"]) const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"], findExecutable)
const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"]) const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"], findExecutable)
const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"]) const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"], findExecutable)
const dart = executable("dart", [".dart"], ["format", "$FILE"]) const dart = executable("dart", [".dart"], ["format", "$FILE"], findExecutable)
const ocamlformat: Info = { const ocamlformat: Info = {
name: "ocamlformat", name: "ocamlformat",
extensions: [".ml", ".mli"], extensions: [".ml", ".mli"],
enabled: Effect.gen(function* () { enabled: Effect.gen(function* () {
if (!(yield* findUp(".ocamlformat")).length) return disabled if (!(yield* findUp(".ocamlformat")).length) return disabled
const match = which("ocamlformat") const match = findExecutable("ocamlformat")
return match ? [match, "-i", "$FILE"] : disabled return match ? [match, "-i", "$FILE"] : disabled
}).pipe(Effect.orElseSucceed(() => disabled)), }).pipe(Effect.orElseSucceed(() => disabled)),
} }
const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"]) const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"], findExecutable)
const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"]) const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"], findExecutable)
const gleam = executable("gleam", [".gleam"], ["format", "$FILE"]) const gleam = executable("gleam", [".gleam"], ["format", "$FILE"], findExecutable)
const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"]) const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"], findExecutable)
const nixfmt = executable("nixfmt", [".nix"], ["$FILE"]) const nixfmt = executable("nixfmt", [".nix"], ["$FILE"], findExecutable)
const rustfmt = executable("rustfmt", [".rs"], ["$FILE"]) const rustfmt = executable("rustfmt", [".rs"], ["$FILE"], findExecutable)
const pint: Info = { const pint: Info = {
name: "pint", name: "pint",
@@ -253,9 +256,9 @@ export function make(input: {
}).pipe(Effect.orElseSucceed(() => disabled)), }).pipe(Effect.orElseSucceed(() => disabled)),
} }
const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"]) const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"], findExecutable)
const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"]) const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"], findExecutable)
const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"]) const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"], findExecutable)
return [ return [
gofmt, gofmt,
@@ -287,12 +290,17 @@ export function make(input: {
] satisfies Info[] ] satisfies Info[]
} }
function executable(name: string, extensions: readonly string[], args: string[]): Info { function executable(
name: string,
extensions: readonly string[],
args: string[],
findExecutable: (name: string) => string | null,
): Info {
return { return {
name, name,
extensions, extensions,
enabled: Effect.sync(() => { enabled: Effect.sync(() => {
const match = which(name) const match = findExecutable(name)
return match ? [match, ...args] : false return match ? [match, ...args] : false
}), }),
} }
+64 -56
View File
@@ -1,15 +1,13 @@
export * as InstructionDiscovery from "./instruction-discovery" export * as InstructionDiscovery from "./instruction-discovery"
import { Array, Context, Effect, Layer, Schema } from "effect" import { Context, Effect, Layer, Schema, Types } from "effect"
import { isAbsolute, join, relative, sep } from "path"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "./location"
import { AbsolutePath } from "./schema"
import { Instructions } from "./instructions/index"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus"
import { Instructions } from "./instructions/index"
import { AbsolutePath } from "./schema"
import { State } from "./state"
class File extends Schema.Class<File>("InstructionDiscovery.File")({ export class File extends Schema.Class<File>("InstructionDiscovery.File")({
path: AbsolutePath, path: AbsolutePath,
content: Schema.String, content: Schema.String,
}) {} }) {}
@@ -17,7 +15,30 @@ class File extends Schema.Class<File>("InstructionDiscovery.File")({
const Files = Schema.Array(File) const Files = Schema.Array(File)
const key = Instructions.Key.make("core/instructions") const key = Instructions.Key.make("core/instructions")
export interface Interface { export const Event = {
Updated: Bus.ephemeral({ type: "instruction-discovery.updated", schema: {} }),
}
export type Data = {
files: Map<AbsolutePath, Types.DeepMutable<File>>
available: boolean
}
export type Draft = {
list: () => readonly Types.DeepMutable<File>[]
// Map insertion order is render order: config adds global then nearest-to-farthest project files;
// sibling contributors interleave by transform registration order.
add: (file: File) => void
update: (path: string, update: (file: Types.DeepMutable<File>) => void) => void
remove: (path: string) => void
unavailable: () => void
}
export interface Interface extends State.Transformable<Draft> {
// Discovery policy lives here because internal plugins have no per-composition options channel.
// Move it into plugin config once plugins can consume their own options.
readonly project: boolean
readonly list: () => Effect.Effect<File[] | Instructions.Unavailable>
readonly load: () => Effect.Effect<Instructions.List> readonly load: () => Effect.Effect<Instructions.List>
} }
@@ -32,9 +53,26 @@ export const layer = (options?: Options) =>
Layer.effect( Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* FSUtil.Service const bus = yield* Bus.Service
const global = yield* Global.Service const state = State.create<Data, Draft>({
const location = yield* Location.Service name: "instruction-discovery",
initial: () => ({ files: new Map(), available: true }),
draft: (draft) => ({
list: () => Array.from(draft.files.values()),
add: (file) => draft.files.set(file.path, new File(file) as Types.DeepMutable<File>),
update: (path, update) => {
const current = draft.files.get(AbsolutePath.make(path))
if (!current) return
update(current)
current.path = AbsolutePath.make(path)
},
remove: (path) => draft.files.delete(AbsolutePath.make(path)),
unavailable: () => {
draft.available = false
},
}),
finalize: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) => const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
Instructions.make<ReadonlyArray<File>>({ Instructions.make<ReadonlyArray<File>>({
@@ -49,52 +87,22 @@ export const layer = (options?: Options) =>
}, },
}) })
const observe = Effect.fn("InstructionDiscovery.observe")(function* () { const list = Effect.fn("InstructionDiscovery.list")(function* () {
const start = yield* fs.resolve(location.directory) const current = state.get()
const stop = yield* fs.resolve(location.project.directory) if (!current.available) return Instructions.unavailable
const fromProject = relative(stop, start) return Array.from(current.files.values())
const insideProject =
fromProject === "" ||
(fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject))
const discovered = new Set(
yield* Effect.forEach(
options?.project === false || !insideProject
? []
: yield* fs.up({
targets: ["AGENTS.md"],
start,
stop,
}),
fs.resolve,
),
)
const paths = Array.dedupe([yield* fs.resolve(join(global.config, "AGENTS.md")), ...discovered])
const files = yield* Effect.forEach(
paths,
(path) =>
fs
.readFileStringSafe(path)
.pipe(
Effect.map((content) =>
content === undefined ? undefined : new File({ path: AbsolutePath.make(path), content }),
),
),
{ concurrency: "unbounded" },
)
if (files.some((file, index) => file === undefined && discovered.has(paths[index])))
return Instructions.unavailable
return files.filter((file): file is File => file !== undefined)
}) })
return Service.of({ return Service.of({
load: () => project: options?.project !== false,
observe().pipe( transform: state.transform,
Effect.map((files) => reload: state.reload,
Array.isArray(files) && files.length === 0 ? source(Instructions.removed) : source(files), list,
), load: Effect.fn("InstructionDiscovery.load")(function* () {
Effect.catch(() => Effect.succeed(source(Instructions.unavailable))), const files = yield* list()
Effect.catchDefect(() => Effect.succeed(source(Instructions.unavailable))), if (!Array.isArray(files)) return source(files)
), return source(files.length === 0 ? Instructions.removed : files)
}),
}) })
}), }),
) )
@@ -103,7 +111,7 @@ export function configured(options?: Options) {
return makeLocationNode({ return makeLocationNode({
service: Service, service: Service,
layer: layer(options), layer: layer(options),
deps: [FSUtil.node, Global.node, Location.node], deps: [Bus.node],
}) })
} }
+70 -54
View File
@@ -1,11 +1,8 @@
import path from "path" import { Cause, Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ModelsDev } from "@opencode-ai/schema/models-dev" import { ModelsDev } from "@opencode-ai/schema/models-dev"
import { Money } from "@opencode-ai/schema/money" import { Money } from "@opencode-ai/schema/money"
import { App } from "./app" import { App } from "./app"
import { Global } from "@opencode-ai/util/global"
import { Flock } from "@opencode-ai/util/flock"
import { Hash } from "@opencode-ai/util/hash" import { Hash } from "@opencode-ai/util/hash"
import { FSUtil } from "@opencode-ai/util/fs-util" import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bus } from "./bus" import { Bus } from "./bus"
@@ -13,6 +10,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform" import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Model } from "./model" import { Model } from "./model"
import { Provider } from "./provider" import { Provider } from "./provider"
import { KV } from "./kv"
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"]) export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type export type CatalogModelStatus = typeof CatalogModelStatus.Type
@@ -537,6 +535,18 @@ export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {} export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
const CatalogJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
const Cache = Schema.Struct({
updatedAt: Schema.Number,
body: CatalogJson,
})
const defaultSource = "https://models.opencode.ai"
function cacheKey(source: string) {
if (source === defaultSource) return "models-dev:catalog"
return `models-dev:catalog:${Hash.fast(source)}`
}
export const layer = (options?: Options) => export const layer = (options?: Options) =>
Layer.effect( Layer.effect(
Service, Service,
@@ -544,6 +554,7 @@ export const layer = (options?: Options) =>
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const bus = yield* Bus.Service const bus = yield* Bus.Service
const app = yield* App.Metadata const app = yield* App.Metadata
const kv = yield* KV.Service
const http = HttpClient.filterStatusOk( const http = HttpClient.filterStatusOk(
(yield* HttpClient.HttpClient).pipe( (yield* HttpClient.HttpClient).pipe(
HttpClient.retryTransient({ HttpClient.retryTransient({
@@ -554,21 +565,28 @@ export const layer = (options?: Options) =>
), ),
) )
const source = options?.url || "https://models.opencode.ai" const source = options?.url || defaultSource
const fetch = options?.fetch ?? true const fetch = options?.fetch ?? true
const userAgent = App.useragent(app) const userAgent = App.useragent(app)
const filepath = path.join( const key = cacheKey(source)
Global.Path.cache,
source === "https://models.opencode.ai" ? "models.json" : `models-${Hash.fast(source)}.json`,
)
const ttl = Duration.minutes(5) const ttl = Duration.minutes(5)
const lockKey = `models-dev:${filepath}` const lock = Semaphore.makeUnsafe(1)
const loadFromCache = Effect.fnUntraced(function* () {
const value = yield* kv.get(key)
const cached = Schema.decodeUnknownOption(Cache)(value)
if (Option.isSome(cached))
return {
catalog: cached.value.body as Record<string, SourceProvider>,
updatedAt: cached.value.updatedAt,
}
if (value !== undefined) yield* kv.remove(key)
})
const fresh = Effect.fnUntraced(function* () { const fresh = Effect.fnUntraced(function* () {
const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined))) const cached = yield* loadFromCache()
if (!stat) return false if (!cached) return false
const mtime = Option.getOrElse(stat.mtime, () => new Date(0)).getTime() return Date.now() - cached.updatedAt < Duration.toMillis(ttl)
return Date.now() - mtime < Duration.toMillis(ttl)
}) })
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () { const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
@@ -580,15 +598,12 @@ export const layer = (options?: Options) =>
) )
}) })
const loadFromDisk = fs.readJson(options?.file ?? filepath).pipe( const loadFromFile = options?.file
Effect.map((input) => input as Record<string, SourceProvider>), ? fs.readJson(options.file).pipe(
Effect.catch((error) => { Effect.map((input) => input as Record<string, SourceProvider>),
if (options?.file === undefined && error._tag === "FileSystemError" && error.method === "readJson") { Effect.catch(() => Effect.succeed(undefined)),
return fs.remove(filepath, { force: true }).pipe(Effect.ignore, Effect.as(undefined)) )
} : Effect.succeed(undefined)
return Effect.succeed(undefined)
}),
)
const loadSnapshot = Effect.sync(() => const loadSnapshot = Effect.sync(() =>
typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV, typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV,
@@ -596,33 +611,36 @@ export const layer = (options?: Options) =>
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () { const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi() const text = yield* fetchApi()
const tempfile = `${filepath}.${process.pid}.${Date.now()}.tmp` const catalog = (yield* Schema.decodeUnknownEffect(CatalogJson)(text)) as Record<string, SourceProvider>
yield* fs.writeWithDirs(tempfile, text).pipe( // Best-effort: a cache-write failure must never kill catalog
Effect.andThen(fs.rename(tempfile, filepath)), // population. The payload has outgrown some KV backends' per-value
Effect.catch((error) => // limits (Durable Object SQLite caps values at 2 MB and api.json
Effect.gen(function* () { // passed it in Aug 2026); a boot without a cache hit just refetches.
yield* fs.remove(tempfile, { force: true }).pipe(Effect.ignore) yield* kv.set(key, { updatedAt: Date.now(), body: text }).pipe(
return yield* Effect.fail(error) Effect.catchCauseIf(
}), (cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
), ),
) )
return text return catalog
}) })
const populate = Effect.gen(function* () { const populate = Effect.gen(function* () {
const fromDisk = yield* loadFromDisk const fromFile = yield* loadFromFile
if (fromDisk) return normalize(fromDisk) if (fromFile) return normalize(fromFile)
const cached = options?.file ? undefined : yield* loadFromCache()
if (cached) return normalize(cached.catalog)
const bundled = yield* loadSnapshot const bundled = yield* loadSnapshot
if (bundled) return normalize(bundled) if (bundled) return normalize(bundled)
if (!fetch) return [] if (!fetch) return []
// Flock is cross-process: concurrent opencode CLIs can race on this cache file. const catalog = yield* lock.withPermit(
const text = yield* Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
yield* Flock.effect(lockKey) const stored = options?.file ? undefined : yield* loadFromCache()
if (stored) return stored.catalog
return yield* fetchAndWrite() return yield* fetchAndWrite()
}), }),
) )
return normalize(JSON.parse(text) as Record<string, SourceProvider>) return normalize(catalog)
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie) }).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity) const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
@@ -630,21 +648,19 @@ export const layer = (options?: Options) =>
const get = (): Effect.Effect<readonly Snapshot[]> => cachedGet const get = (): Effect.Effect<readonly Snapshot[]> => cachedGet
const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) { const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
if (!force && (yield* fresh())) return yield* lock
yield* Effect.scoped( .withPermit(
Effect.gen(function* () { Effect.gen(function* () {
yield* Flock.effect(lockKey) if (!force && (yield* fresh())) return
// Re-check under the lock: another process may have refreshed between yield* fetchAndWrite()
// our outer check and lock acquisition. yield* invalidate
if (!force && (yield* fresh())) return yield* bus.publish(ModelsDev.Event.Refreshed, {})
yield* fetchAndWrite() }),
yield* invalidate )
yield* bus.publish(ModelsDev.Event.Refreshed, {}) .pipe(
}), Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
).pipe( Effect.ignore,
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })), )
Effect.ignore,
)
}) })
if (fetch && !process.argv.includes("--get-yargs-completions")) { if (fetch && !process.argv.includes("--get-yargs-completions")) {
@@ -660,7 +676,7 @@ export function configured(options?: Options) {
return makeGlobalNode({ return makeGlobalNode({
service: Service, service: Service,
layer: layer(options), layer: layer(options),
deps: [FSUtil.node, Bus.node, App.node, httpClient], deps: [FSUtil.node, Bus.node, App.node, KV.node, httpClient],
}) })
} }
+19 -7
View File
@@ -3,7 +3,7 @@ export * as PluginHooks from "./hooks"
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk" import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session" import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell" import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool" import type { ToolFailures, ToolHooks } from "@opencode-ai/plugin/effect/tool"
import { Context, Effect, Layer, Scope } from "effect" import { Context, Effect, Layer, Scope } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { State } from "../state" import { State } from "../state"
@@ -15,19 +15,29 @@ export interface Domains {
readonly tool: ToolHooks readonly tool: ToolHooks
} }
type Callback<Event> = (event: Event) => Effect.Effect<void> type NoFailures<Spec> = { readonly [Name in keyof Spec]: never }
// Failure channel for each hook event. Only tool execute.before may fail: a Tool.Error rejects the call before it runs.
interface Failures extends Record<keyof Domains, unknown> {
readonly aisdk: NoFailures<AISDKHooks>
readonly session: NoFailures<SessionHooks>
readonly shell: NoFailures<ShellHooks>
readonly tool: ToolFailures
}
type Callback<Event, Error> = (event: Event) => Effect.Effect<void, Error>
export interface Interface { export interface Interface {
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>( readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
domain: Domain, domain: Domain,
name: Name, name: Name,
callback: Callback<Domains[Domain][Name]>, callback: Callback<Domains[Domain][Name], Failures[Domain][Name]>,
) => Effect.Effect<State.Registration, never, Scope.Scope> ) => Effect.Effect<State.Registration, never, Scope.Scope>
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>( readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
domain: Domain, domain: Domain,
name: Name, name: Name,
event: Domains[Domain][Name], event: Domains[Domain][Name],
) => Effect.Effect<Domains[Domain][Name]> ) => Effect.Effect<Domains[Domain][Name], Failures[Domain][Name]>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginHooks") {} export class Service extends Context.Service<Service, Interface>()("@opencode/PluginHooks") {}
@@ -56,7 +66,9 @@ const layer = Layer.effect(
const trigger: Interface["trigger"] = Effect.fn("PluginHooks.trigger")(function* (domain, name, event) { const trigger: Interface["trigger"] = Effect.fn("PluginHooks.trigger")(function* (domain, name, event) {
for (const callback of callbacks.get(key(domain, name)) ?? []) { for (const callback of callbacks.get(key(domain, name)) ?? []) {
const result: Effect.Effect<void> = Reflect.apply(callback, undefined, [event]) const result: Effect.Effect<void, Failures[typeof domain][typeof name]> = Reflect.apply(callback, undefined, [
event,
])
yield* result yield* result
} }
return event return event
+4 -2
View File
@@ -290,8 +290,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
transform: (callback) => transform: (callback) =>
skill.transform((draft) => { skill.transform((draft) => {
callback({ callback({
source: (source) => draft.source(Schema.decodeUnknownSync(Skill.Source)(source)), list: () => mutable(draft.list()),
list: draft.list, add: (value) => draft.add(Schema.decodeUnknownSync(Skill.Info)(value)),
update: draft.update,
remove: draft.remove,
}) })
}), }),
}, },
+51
View File
@@ -1,6 +1,8 @@
export * as PluginInternal from "./internal" export * as PluginInternal from "./internal"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin" import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Context, Effect, Scope } from "effect" import { Context, Effect, Scope } from "effect"
import { HttpClient } from "effect/unstable/http" import { HttpClient } from "effect/unstable/http"
import { Agent } from "../agent" import { Agent } from "../agent"
@@ -10,6 +12,7 @@ import { Config } from "../config"
import { Credential } from "../credential" import { Credential } from "../credential"
import { ConfigAgentPlugin } from "../config/plugin/agent" import { ConfigAgentPlugin } from "../config/plugin/agent"
import { ConfigCommandPlugin } from "../config/plugin/command" import { ConfigCommandPlugin } from "../config/plugin/command"
import { ConfigInstructionPlugin } from "../config/plugin/instruction"
import { ConfigProviderPlugin } from "../config/plugin/provider" import { ConfigProviderPlugin } from "../config/plugin/provider"
import { ConfigPolicyPlugin } from "../config/plugin/policy" import { ConfigPolicyPlugin } from "../config/plugin/policy"
import { ConfigReferencePlugin } from "../config/plugin/reference" import { ConfigReferencePlugin } from "../config/plugin/reference"
@@ -24,6 +27,7 @@ import { FileSystem } from "../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util" import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { Image } from "../image" import { Image } from "../image"
import { InstructionDiscovery } from "../instruction-discovery"
import { Integration } from "../integration" import { Integration } from "../integration"
import { KV } from "../kv" import { KV } from "../kv"
import { Location } from "../location" import { Location } from "../location"
@@ -37,6 +41,8 @@ import { Ripgrep } from "../ripgrep"
import { SessionInstructions } from "../session/instructions" import { SessionInstructions } from "../session/instructions"
import { Shell } from "../shell" import { Shell } from "../shell"
import { Skill } from "../skill" import { Skill } from "../skill"
import { SkillDiscovery } from "../skill/discovery"
import { Watcher } from "../filesystem/watcher"
import { PatchTool } from "../tool/plugin/patch" import { PatchTool } from "../tool/plugin/patch"
import { EditTool } from "../tool/plugin/edit" import { EditTool } from "../tool/plugin/edit"
import { GlobTool } from "../tool/plugin/glob" import { GlobTool } from "../tool/plugin/glob"
@@ -79,6 +85,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const global = yield* Global.Service const global = yield* Global.Service
const http = yield* HttpClient.HttpClient const http = yield* HttpClient.HttpClient
const image = yield* Image.Service const image = yield* Image.Service
const instructionDiscovery = yield* InstructionDiscovery.Service
const integration = yield* Integration.Service const integration = yield* Integration.Service
const kv = yield* KV.Service const kv = yield* KV.Service
const location = yield* Location.Service const location = yield* Location.Service
@@ -95,7 +102,9 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const instructions = yield* SessionInstructions.Service const instructions = yield* SessionInstructions.Service
const shell = yield* Shell.Service const shell = yield* Shell.Service
const skill = yield* Skill.Service const skill = yield* Skill.Service
const skillDiscovery = yield* SkillDiscovery.Service
const tools = yield* Tool.Service const tools = yield* Tool.Service
const watcher = yield* Watcher.Service
const wellknown = yield* WellKnown.Service const wellknown = yield* WellKnown.Service
return Context.mergeAll( return Context.mergeAll(
Context.make(Agent.Service, agent), Context.make(Agent.Service, agent),
@@ -112,6 +121,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Global.Service, global), Context.make(Global.Service, global),
Context.make(HttpClient.HttpClient, http), Context.make(HttpClient.HttpClient, http),
Context.make(Image.Service, image), Context.make(Image.Service, image),
Context.make(InstructionDiscovery.Service, instructionDiscovery),
Context.make(Integration.Service, integration), Context.make(Integration.Service, integration),
Context.make(KV.Service, kv), Context.make(KV.Service, kv),
Context.make(Location.Service, location), Context.make(Location.Service, location),
@@ -128,7 +138,9 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(SessionInstructions.Service, instructions), Context.make(SessionInstructions.Service, instructions),
Context.make(Shell.Service, shell), Context.make(Shell.Service, shell),
Context.make(Skill.Service, skill), Context.make(Skill.Service, skill),
Context.make(SkillDiscovery.Service, skillDiscovery),
Context.make(Tool.Service, tools), Context.make(Tool.Service, tools),
Context.make(Watcher.Service, watcher),
Context.make(WellKnown.Service, wellknown), Context.make(WellKnown.Service, wellknown),
) )
}) })
@@ -137,6 +149,44 @@ type ContextServices<A> = A extends Context.Context<infer R> ? R : never
export type Requirements = ContextServices<Effect.Success<ReturnType<typeof services>>> export type Requirements = ContextServices<Effect.Success<ReturnType<typeof services>>>
export const requirements = LayerNode.group([
Agent.node,
Catalog.node,
Command.node,
Config.node,
Credential.node,
Bus.node,
Environment.node,
FileMutation.node,
Formatter.node,
FileSystem.node,
FSUtil.node,
Global.node,
httpClient,
Image.node,
InstructionDiscovery.node,
Integration.node,
KV.node,
Location.node,
LocationMutation.node,
ModelsDev.node,
Npm.node,
Permission.node,
PluginRuntime.node,
Form.node,
ReadToolFileSystem.node,
Reference.node,
WebSearch.node,
Ripgrep.node,
SessionInstructions.node,
Shell.node,
Skill.node,
SkillDiscovery.node,
Tool.node,
Watcher.node,
WellKnown.node,
])
export type InternalPlugin = Plugin<Requirements | Scope.Scope> export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [ const pre = [
@@ -164,6 +214,7 @@ const pre = [
] as const satisfies readonly InternalPlugin[] ] as const satisfies readonly InternalPlugin[]
const post = [ const post = [
ConfigInstructionPlugin.Plugin,
ConfigReferencePlugin.Plugin, ConfigReferencePlugin.Plugin,
ConfigAgentPlugin.Plugin, ConfigAgentPlugin.Plugin,
ConfigCommandPlugin.Plugin, ConfigCommandPlugin.Plugin,
+1
View File
@@ -36,6 +36,7 @@ export const ModelsDevPlugin = define({
draft.integrationID = Integration.ID.make(provider.info.id) draft.integrationID = Integration.ID.make(provider.info.id)
}) })
for (const model of provider.models) { for (const model of provider.models) {
if (model.status === "deprecated") continue
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, model)) catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, model))
} }
} }
@@ -8,14 +8,13 @@ import { iife } from "../../util/iife"
import { configuredSettings } from "./configured" import { configuredSettings } from "./configured"
const providerID = Provider.ID.make("cloudflare-workers-ai") const providerID = Provider.ID.make("cloudflare-workers-ai")
const nativePackage = "@opencode-ai/ai/providers/cloudflare-workers-ai"
export const CloudflareWorkersAIPlugin = define({ export const CloudflareWorkersAIPlugin = define({
id: "opencode.provider.cloudflare-workers-ai", id: "opencode.provider.cloudflare-workers-ai",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const configured = yield* configuredSettings(providerID) const configured = yield* configuredSettings(providerID)
const form = iife(() => { const form = iife(() => {
if (hasExplicitEndpoint(configured?.baseURL) || resolveAccountId(configured ?? {})) return if (typeof configured?.baseURL === "string" || resolveAccountId(configured ?? {})) return
return Form.Fields.make([ return Form.Fields.make([
{ {
type: "string", type: "string",
@@ -39,24 +38,12 @@ export const CloudflareWorkersAIPlugin = define({
yield* ctx.catalog.transform((evt) => { yield* ctx.catalog.transform((evt) => {
const item = evt.provider.get(providerID) const item = evt.provider.get(providerID)
if (!item) return if (!item) return
const compatible =
Provider.isAISDK(item.provider.package) &&
Provider.packageName(item.provider.package) === "@ai-sdk/openai-compatible"
evt.provider.update(item.provider.id, (provider) => { evt.provider.update(item.provider.id, (provider) => {
if (!compatible) return if (!Provider.isAISDK(provider.package)) return
provider.package = nativePackage if (typeof provider.settings?.baseURL === "string") return
provider.settings = nativeSettings(provider.settings) const accountId = resolveAccountId(provider.settings ?? {})
if (accountId) provider.settings = { ...provider.settings, baseURL: workersEndpoint(accountId) }
}) })
for (const model of item.models.values()) {
evt.model.update(item.provider.id, model.id, (draft) => {
if (!draft.package && !compatible) return
if (draft.package === nativePackage) return
if (draft.package && !Provider.isAISDK(draft.package)) return
if (draft.package && Provider.packageName(draft.package) !== "@ai-sdk/openai-compatible") return
if (draft.package) draft.package = nativePackage
draft.settings = nativeSettings(draft.settings)
})
}
}) })
yield* ctx.aisdk.hook( yield* ctx.aisdk.hook(
"sdk", "sdk",
@@ -96,17 +83,6 @@ function workersEndpoint(accountId: string) {
return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1` return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`
} }
function hasExplicitEndpoint(baseURL: unknown) {
return typeof baseURL === "string" && !baseURL.includes("${CLOUDFLARE_ACCOUNT_ID}")
}
function nativeSettings(settings: Record<string, unknown> | undefined) {
const result = { ...settings }
if (process.env.CLOUDFLARE_ACCOUNT_ID) result.baseURL = workersEndpoint(process.env.CLOUDFLARE_ACCOUNT_ID)
else if (!hasExplicitEndpoint(result.baseURL)) delete result.baseURL
return result
}
function hasWorkersEndpoint(model: { function hasWorkersEndpoint(model: {
readonly package?: string readonly package?: string
readonly settings?: Readonly<Record<string, unknown>> readonly settings?: Readonly<Record<string, unknown>>
@@ -117,7 +93,7 @@ function hasWorkersEndpoint(model: {
function sdkOptions(options: Record<string, any>, app: App.Info) { function sdkOptions(options: Record<string, any>, app: App.Info) {
return { return {
...options, ...options,
baseURL: expandAccountId(options.baseURL, resolveAccountId(options)), baseURL: expandAccountId(options.baseURL),
apiKey: process.env.CLOUDFLARE_API_KEY ?? options.apiKey, apiKey: process.env.CLOUDFLARE_API_KEY ?? options.apiKey,
headers: { headers: {
"User-Agent": `${App.useragent(app)} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`, "User-Agent": `${App.useragent(app)} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
@@ -127,9 +103,9 @@ function sdkOptions(options: Record<string, any>, app: App.Info) {
} }
} }
function expandAccountId(baseURL: unknown, accountId: string | undefined) { function expandAccountId(baseURL: unknown) {
if (typeof baseURL !== "string") return baseURL if (typeof baseURL !== "string") return baseURL
return baseURL.replaceAll("${CLOUDFLARE_ACCOUNT_ID}", accountId ?? "${CLOUDFLARE_ACCOUNT_ID}") return baseURL.replaceAll("${CLOUDFLARE_ACCOUNT_ID}", process.env.CLOUDFLARE_ACCOUNT_ID ?? "${CLOUDFLARE_ACCOUNT_ID}")
} }
function stringOption(options: Record<string, unknown>, key: string) { function stringOption(options: Record<string, unknown>, key: string) {
+15 -21
View File
@@ -28,29 +28,23 @@ export const Plugin = define({
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const reportContent = yield* reportContentWithDiagnostics(ctx.app) const reportContent = yield* reportContentWithDiagnostics(ctx.app)
yield* ctx.skill.transform((draft) => { yield* ctx.skill.transform((draft) => {
draft.source( draft.add(
Skill.EmbeddedSource.make({ Skill.Info.make({
type: "embedded", id: Skill.ID.make("opencode"),
skill: Skill.Info.make({ name: Skill.Name.make("OpenCode"),
id: Skill.ID.make("opencode"), description: OpencodeDescription,
name: Skill.Name.make("OpenCode"), location: AbsolutePath.make("/builtin/opencode.md"),
description: OpencodeDescription, content: OpencodeContent,
location: AbsolutePath.make("/builtin/opencode.md"),
content: OpencodeContent,
}),
}), }),
) )
draft.source( draft.add(
Skill.EmbeddedSource.make({ Skill.Info.make({
type: "embedded", id: Skill.ID.make("report"),
skill: Skill.Info.make({ name: Skill.Name.make("Report"),
id: Skill.ID.make("report"), description: REPORT_DESCRIPTION,
name: Skill.Name.make("Report"), slash: true,
description: REPORT_DESCRIPTION, location: AbsolutePath.make("/builtin/report.md"),
slash: true, content: reportContent,
location: AbsolutePath.make("/builtin/report.md"),
content: reportContent,
}),
}), }),
) )
}) })
+21 -208
View File
@@ -1,48 +1,17 @@
export * as PluginSupervisor from "./supervisor" export * as PluginSupervisor from "./supervisor"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin" import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { Directory, Document, Event, type Entry } from "@opencode-ai/schema/config" import { Event } from "@opencode-ai/schema/config"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin" import { Context, Deferred, Effect, Layer, Schema, Stream } from "effect"
import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import path from "path" import path from "path"
import { fileURLToPath, pathToFileURL } from "url" import { pathToFileURL } from "url"
import { Agent } from "../agent" import { ConfigPluginSource } from "../config/plugin/source"
import { Catalog } from "../catalog"
import { Command } from "../command"
import { Config } from "../config"
import { Credential } from "../credential"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Bus } from "../bus" import { Bus } from "../bus"
import { Environment } from "../environment"
import { FileMutation } from "../file-mutation"
import { Formatter } from "../formatter"
import { FileSystem } from "../filesystem"
import { Watcher } from "../filesystem/watcher"
import { Form } from "../form"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Image } from "../image"
import { Integration } from "../integration"
import { KV } from "../kv"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { ModelsDev } from "../models-dev"
import { Npm } from "@opencode-ai/util/npm" import { Npm } from "@opencode-ai/util/npm"
import { Permission } from "../permission"
import { Plugin } from "../plugin" import { Plugin } from "../plugin"
import { PluginPromise } from "../plugin/promise" import { PluginPromise } from "../plugin/promise"
import { Reference } from "../reference"
import { Ripgrep } from "../ripgrep"
import { SessionInstructions } from "../session/instructions"
import { Shell } from "../shell"
import { Skill } from "../skill"
import { ReadToolFileSystem } from "../tool/read-filesystem"
import { Tool } from "../tool"
import { WebSearch } from "../websearch"
import { WellKnown } from "../wellknown"
import { PluginInternal } from "./internal" import { PluginInternal } from "./internal"
import { PluginRuntime } from "./runtime"
import { SdkPlugins } from "./sdk" import { SdkPlugins } from "./sdk"
import { importModule } from "@opencode-ai/util/runtime-import" import { importModule } from "@opencode-ai/util/runtime-import"
@@ -63,65 +32,10 @@ const PluginModule = Schema.Struct({
]), ]),
}) })
type Operation =
| {
readonly type: "add"
readonly target: string
readonly options: Record<string, unknown>
readonly mtime?: number
}
| {
readonly type: "remove"
readonly target: string
}
function parse(input: ConfigPlugin.Plugin): Operation {
if (typeof input !== "string") {
return { type: "add", target: input.package, options: input.options ?? {} }
}
if (!input.startsWith("-")) return { type: "add", target: input, options: {} }
if (input.length === 1) throw new Error("Plugin remove operation requires a target")
return { type: "remove", target: input.slice(1) }
}
const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Entry[]) {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const discovered = yield* Effect.forEach(
entries.filter((entry): entry is Directory => entry.type === "directory"),
(entry) => discoverDirectory(fs, entry.path),
).pipe(Effect.map((items) => items.flat()))
const configured = entries
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) =>
(entry.info.plugins ?? []).map(parse).map((operation) => {
if (operation.type === "remove") return operation
const directory = entry.path ? path.dirname(entry.path) : location.directory
const target = operation.target.startsWith("file://")
? fileURLToPath(operation.target)
: operation.target.startsWith("./") || operation.target.startsWith("../")
? path.resolve(directory, operation.target)
: operation.target
return { ...operation, target }
}),
)
// Explicit config is applied last so it can remove auto-discovered packages.
return yield* Effect.forEach([...discovered, ...configured], (operation) => {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
return fs.stat(operation.target).pipe(
Effect.map((info) => ({
...operation,
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
})),
Effect.catch(() => Effect.succeed(operation)),
)
})
})
const resolve = Effect.fn("PluginSupervisor.resolve")(function* ( const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
pre: readonly Plugin.Versioned[], pre: readonly Plugin.Versioned[],
post: readonly Plugin.Versioned[], post: readonly Plugin.Versioned[],
operations: readonly Operation[], operations: readonly ConfigPluginSource.Operation[],
) { ) {
const matches = (selector: string, target: string) => const matches = (selector: string, target: string) =>
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target) selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
@@ -168,7 +82,9 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
] ]
}) })
const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract<Operation, { type: "add" }>) { const load = Effect.fn("PluginSupervisor.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
) {
const npm = yield* Npm.Service const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(operation.target) const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href ? pathToFileURL(operation.target).href
@@ -192,31 +108,6 @@ const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract<Op
} satisfies Plugin.Versioned } satisfies Plugin.Versioned
}) })
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const files = yield* fs
.scan("{plugin,plugins}/*.{ts,js}", {
cwd: directory,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
return files.sort().map((target): Operation => ({ type: "add", target, options: {} }))
})
}
const sourceDirectories = ["plugin", "plugins"] as const
function isPluginSource(entries: readonly Entry[], file: string) {
return entries.some(
(entry) =>
entry.type === "directory" &&
sourceDirectories.some((directory) => FSUtil.contains(path.join(entry.path, directory), file)),
)
}
export interface Interface { export interface Interface {
/** Wait for the initial plugin generation and startup updates to settle. */ /** Wait for the initial plugin generation and startup updates to settle. */
readonly flush: Effect.Effect<void> readonly flush: Effect.Effect<void>
@@ -224,72 +115,29 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {} export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* Plugin.Service const registry = yield* Plugin.Service
const sdk = yield* SdkPlugins.Service const sdk = yield* SdkPlugins.Service
const config = yield* Config.Service const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service const bus = yield* Bus.Service
const watcher = yield* Watcher.Service
const fs = yield* FSUtil.Service
const ready = { current: yield* Deferred.make<void>() } const ready = { current: yield* Deferred.make<void>() }
let observed = 0 let observed = 0
// Configured local plugin files can live outside config roots, where the
// config change feed cannot see them; watch those entrypoints directly.
// Watches start on first sighting and are never torn down individually:
// a stale watch after a config edit costs one deduped fs handle and a
// no-op activation, and every watch dies with this layer's scope.
const configuredChanges = yield* PubSub.unbounded<void>()
const watched = new Set<string>()
const watchConfiguredSources = Effect.fn("PluginSupervisor.watchConfiguredSources")(function* (
entries: readonly Entry[],
operations: readonly Operation[],
) {
for (const operation of operations) {
if (operation.type !== "add" || !path.isAbsolute(operation.target)) continue
if (watched.has(operation.target)) continue
// The config change feed already covers {plugin,plugins} directories.
if (isPluginSource(entries, operation.target)) continue
// Directory targets can't hot-reload (their stat mtime ignores edits
// inside), so don't watch what can't trigger anything.
if (yield* fs.isDir(operation.target)) continue
watched.add(operation.target)
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
yield* updates.pipe(
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
Effect.catchCause((cause) =>
Effect.logError("configured plugin watch failed", { target: operation.target, cause }),
),
Effect.forkScoped({ startImmediately: true }),
)
}
})
const activate = Effect.fn("PluginSupervisor.activate")(function* () { const activate = Effect.fn("PluginSupervisor.activate")(function* () {
// Resolve OpenCode's internal plugins with their privileged Location services. // Resolve OpenCode's internal plugins with their privileged Location services.
const internal = yield* PluginInternal.list() const internal = yield* PluginInternal.list()
// Combine internal plugins with host-contributed SDK plugins in boot order. // Combine internal plugins with host-contributed SDK plugins in boot order.
const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()] const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()]
const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" })) const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" }))
const entries = yield* config.entries() const operations = yield* sources.operations()
const operations = yield* scan(entries)
yield* watchConfiguredSources(entries, operations)
// Apply config operations and load enabled package plugins into one ordered generation. // Apply config operations and load enabled package plugins into one ordered generation.
const plugins = yield* resolve(pre, post, operations) const plugins = yield* resolve(pre, post, operations)
// Replace the active generation in one scoped, batched activation. // Replace the active generation in one scoped, batched activation.
yield* registry.activate(plugins) yield* registry.activate(plugins)
}) })
const updates = Stream.merge( const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
config.changes().pipe(
Stream.filterEffect((update) =>
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
),
Stream.merge(Stream.fromPubSub(configuredChanges)),
),
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
).pipe(
// Make accepted work visible to flush before coalescing the burst. // Make accepted work visible to flush before coalescing the burst.
Stream.mapEffect(() => Stream.mapEffect(() =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -315,48 +163,13 @@ const layer = Layer.effect(
}), }),
) )
const nodeLayer = layer as Layer.Layer<Service, never, PluginInternal.Requirements> const nodeDeps = [
Plugin.node,
SdkPlugins.node,
ConfigPluginSource.node,
Bus.node,
Npm.node,
PluginInternal.requirements,
] as const
export const node = makeLocationNode({ export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
service: Service,
layer: nodeLayer,
deps: [
Plugin.node,
SdkPlugins.node,
Agent.node,
Catalog.node,
Command.node,
Config.node,
Credential.node,
Bus.node,
Environment.node,
FileMutation.node,
Formatter.node,
FileSystem.node,
FSUtil.node,
Global.node,
httpClient,
Image.node,
Integration.node,
KV.node,
Location.node,
LocationMutation.node,
ModelsDev.node,
Npm.node,
Permission.node,
PluginRuntime.node,
Form.node,
ReadToolFileSystem.node,
Reference.node,
Ripgrep.node,
SessionInstructions.node,
Shell.node,
Skill.node,
Tool.node,
Watcher.node,
WebSearch.node,
WellKnown.node,
],
})
export { layer }
+9 -2
View File
@@ -9,6 +9,7 @@ import { Bus } from "./bus"
import { Location } from "./location" import { Location } from "./location"
import { PtyID } from "./pty/schema" import { PtyID } from "./pty/schema"
import { ShellSelect } from "./shell/select" import { ShellSelect } from "./shell/select"
import { Global } from "@opencode-ai/util/global"
import { lazy } from "./util/lazy" import { lazy } from "./util/lazy"
const BUFFER_LIMIT = 1024 * 1024 * 2 const BUFFER_LIMIT = 1024 * 1024 * 2
@@ -96,6 +97,7 @@ export const layer = (options?: ShellSelect.Options) =>
const bus = yield* Bus.Service const bus = yield* Bus.Service
const location = yield* Location.Service const location = yield* Location.Service
const config = yield* Config.Service const config = yield* Config.Service
const global = yield* Global.Service
const context = yield* Effect.context() const context = yield* Effect.context()
const runFork = Effect.runForkWith(context) const runFork = Effect.runForkWith(context)
const sessions = new Map<PtyID, Active>() const sessions = new Map<PtyID, Active>()
@@ -165,7 +167,8 @@ export const layer = (options?: ShellSelect.Options) =>
const create = Effect.fn("Pty.create")(function* (input: CreateInput) { const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const id = PtyID.ascending() const id = PtyID.ascending()
const command = input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options) const command =
input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options, global.bin)
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])] const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
const cwd = input.cwd || location.directory const cwd = input.cwd || location.directory
const env = { const env = {
@@ -315,7 +318,11 @@ export const layer = (options?: ShellSelect.Options) =>
) )
export function configured(options?: ShellSelect.Options) { export function configured(options?: ShellSelect.Options) {
return makeLocationNode({ service: Service, layer: layer(options), deps: [Bus.node, Location.node, Config.node] }) return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Bus.node, Location.node, Config.node, Global.node],
})
} }
export const node = configured() export const node = configured()
+11 -7
View File
@@ -34,6 +34,8 @@ export namespace RipgrepBinary {
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient) const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
const spawner = yield* ChildProcessSpawner const spawner = yield* ChildProcessSpawner
const global = yield* Global.Service
const findExecutable = (name: string) => which(name, undefined, global.bin)
const run = Effect.fnUntraced(function* (command: string, args: string[]) { const run = Effect.fnUntraced(function* (command: string, args: string[]) {
const handle = yield* spawner.spawn(ChildProcess.make(command, args, { extendEnv: true, stdin: "ignore" })) const handle = yield* spawner.spawn(ChildProcess.make(command, args, { extendEnv: true, stdin: "ignore" }))
@@ -53,10 +55,12 @@ export namespace RipgrepBinary {
config: (typeof PLATFORM)[keyof typeof PLATFORM], config: (typeof PLATFORM)[keyof typeof PLATFORM],
target: string, target: string,
) { ) {
const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" }) const dir = yield* fs.makeTempDirectoryScoped({ directory: global.bin, prefix: "ripgrep-" })
if (config.extension === "zip") { if (config.extension === "zip") {
const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe" const shell =
(yield* Effect.sync(() => findExecutable("powershell.exe") ?? findExecutable("pwsh.exe"))) ??
"powershell.exe"
const result = yield* run(shell, [ const result = yield* run(shell, [
"-NoProfile", "-NoProfile",
"-NonInteractive", "-NonInteractive",
@@ -91,10 +95,10 @@ export namespace RipgrepBinary {
return Service.of({ return Service.of({
filepath: yield* Effect.cached( filepath: yield* Effect.cached(
Effect.gen(function* () { Effect.gen(function* () {
const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg")) const system = yield* Effect.sync(() => findExecutable(process.platform === "win32" ? "rg.exe" : "rg"))
if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system
const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`) const target = path.join(global.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
if (yield* fs.isFile(target).pipe(Effect.orDie)) return target if (yield* fs.isFile(target).pipe(Effect.orDie)) return target
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
@@ -103,10 +107,10 @@ export namespace RipgrepBinary {
const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}` const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}`
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}` const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
const archive = path.join(Global.Path.bin, filename) const archive = path.join(global.bin, filename)
yield* Effect.logInfo("downloading ripgrep", { url }) yield* Effect.logInfo("downloading ripgrep", { url })
yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie) yield* fs.ensureDir(global.bin).pipe(Effect.orDie)
const bytes = yield* HttpClientRequest.get(url).pipe( const bytes = yield* HttpClientRequest.get(url).pipe(
http.execute, http.execute,
Effect.flatMap((response) => response.arrayBuffer), Effect.flatMap((response) => response.arrayBuffer),
@@ -127,6 +131,6 @@ export namespace RipgrepBinary {
export const node = makeGlobalNode({ export const node = makeGlobalNode({
service: Service, service: Service,
layer: layer, layer: layer,
deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node], deps: [FSUtil.node, Global.node, httpClient, CrossSpawnSpawner.node],
}) })
} }
+3 -1
View File
@@ -716,10 +716,11 @@ const layer = Layer.effect(
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) .pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
}), }),
switchAgent: Effect.fn("Session.switchAgent")(function* (input) { switchAgent: Effect.fn("Session.switchAgent")(function* (input) {
yield* result.get(input.sessionID) const session = yield* result.get(input.sessionID)
yield* bus.publish(SessionEvent.AgentSelected, { yield* bus.publish(SessionEvent.AgentSelected, {
sessionID: input.sessionID, sessionID: input.sessionID,
agent: input.agent, agent: input.agent,
previous: session.agent,
}) })
}), }),
switchModel: Effect.fn("Session.switchModel")(function* (input) { switchModel: Effect.fn("Session.switchModel")(function* (input) {
@@ -733,6 +734,7 @@ const layer = Layer.effect(
yield* bus.publish(SessionEvent.ModelSelected, { yield* bus.publish(SessionEvent.ModelSelected, {
sessionID: input.sessionID, sessionID: input.sessionID,
model: input.model, model: input.model,
previous: session.model,
}) })
}), }),
rename: Effect.fn("Session.rename")(function* (input) { rename: Effect.fn("Session.rename")(function* (input) {
+15 -10
View File
@@ -4,6 +4,7 @@ import { SessionEvent } from "./event"
import { SessionMessage } from "./message" import { SessionMessage } from "./message"
export interface Adapter { export interface Adapter {
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined, never, never>
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never> readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never> readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
readonly getAssistant: ( readonly getAssistant: (
@@ -59,19 +60,23 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.created": () => Effect.void, "session.created": () => Effect.void,
"session.usage.recorded": () => Effect.void, "session.usage.recorded": () => Effect.void,
"session.agent.selected": (event) => { "session.agent.selected": (event) => {
return adapter.appendMessage( return Effect.gen(function* () {
SessionMessage.AgentSelected.make({ const previous = event.data.previous ?? (yield* adapter.getAgent())
id: SessionMessage.ID.fromEvent(event.id), yield* adapter.appendMessage(
type: "agent-switched", SessionMessage.AgentSelected.make({
metadata: event.metadata, id: SessionMessage.ID.fromEvent(event.id),
agent: event.data.agent, type: "agent-switched",
time: { created: event.created }, metadata: event.metadata,
}), agent: event.data.agent,
) previous,
time: { created: event.created },
}),
)
})
}, },
"session.model.selected": (event) => { "session.model.selected": (event) => {
return Effect.gen(function* () { return Effect.gen(function* () {
const previous = yield* adapter.getModel() const previous = event.data.previous ?? (yield* adapter.getModel())
yield* adapter.appendMessage( yield* adapter.appendMessage(
SessionMessage.ModelSelected.make({ SessionMessage.ModelSelected.make({
id: SessionMessage.ID.fromEvent(event.id), id: SessionMessage.ID.fromEvent(event.id),
+21 -6
View File
@@ -5,6 +5,7 @@ import { DateTime, Effect, Layer, Schema, Stream } from "effect"
import { Database } from "../database/database" import { Database } from "../database/database"
import { Bus } from "../bus" import { Bus } from "../bus"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Agent } from "../agent"
import { Model } from "../model" import { Model } from "../model"
import { SessionEvent } from "./event" import { SessionEvent } from "./event"
import { SessionMessage } from "./message" import { SessionMessage } from "./message"
@@ -230,6 +231,17 @@ function run(db: DatabaseService, event: MessageEvent) {
} }
const appendMessage = (message: SessionMessage.Info) => insertMessage(db, event, message) const appendMessage = (message: SessionMessage.Info) => insertMessage(db, event, message)
const adapter: SessionMessageUpdater.Adapter = { const adapter: SessionMessageUpdater.Adapter = {
getAgent() {
return db
.select({ agent: SessionTable.agent })
.from(SessionTable)
.where(eq(SessionTable.id, event.data.sessionID))
.get()
.pipe(
Effect.orDie,
Effect.map((row) => (row?.agent ? Agent.ID.make(row.agent) : undefined)),
)
},
getModel() { getModel() {
return db return db
.select({ model: SessionTable.model }) .select({ model: SessionTable.model })
@@ -398,12 +410,15 @@ const layer = Layer.effectDiscard(
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie), db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
) )
yield* bus.project(SessionEvent.AgentSelected, (event) => yield* bus.project(SessionEvent.AgentSelected, (event) =>
db Effect.gen(function* () {
.update(SessionTable) yield* run(db, event)
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) }) yield* db
.where(eq(SessionTable.id, event.data.sessionID)) .update(SessionTable)
.run() .set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) })
.pipe(Effect.orDie, Effect.andThen(run(db, event))), .where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
}),
) )
yield* bus.project(SessionEvent.ModelSelected, (event) => yield* bus.project(SessionEvent.ModelSelected, (event) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -59,6 +59,25 @@ const attachmentContent = (file: FileAttachment): ContentPart[] => {
return [] return []
} }
const userAttachmentContent = (files: readonly FileAttachment[]) => {
const eligible = files.filter(
(file) => imageMimes.has(file.mime) && file.source.type === "inline" && file.mention?.text,
)
if (eligible.length < 2) return files.flatMap(attachmentContent)
const seen = new Map<string, Set<string>>()
return files.flatMap((file) => {
if (!imageMimes.has(file.mime) || file.source.type !== "inline" || !file.mention?.text)
return attachmentContent(file)
const metadata = JSON.stringify([file.mime, file.name ?? null, file.description ?? null, file.mention.text])
const payloads = seen.get(metadata) ?? new Set<string>()
if (payloads.has(file.data)) return []
payloads.add(file.data)
seen.set(metadata, payloads)
return attachmentContent(file)
})
}
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const providerMetadata = ( const providerMetadata = (
@@ -186,7 +205,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
const content = [ const content = [
...(message.skills ?? []).map((skill) => Message.text(skill.text)), ...(message.skills ?? []).map((skill) => Message.text(skill.text)),
...(message.text === "" ? [] : [Message.text(message.text)]), ...(message.text === "" ? [] : [Message.text(message.text)]),
...(message.files ?? []).flatMap(attachmentContent), ...userAttachmentContent(message.files ?? []),
] ]
if (content.length === 0) return [] if (content.length === 0) return []
return [ return [
+3 -1
View File
@@ -143,7 +143,9 @@ export const layer = (options?: ShellSelect.Options) =>
}) })
const resolve = () => const resolve = () =>
config.entries().pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options))) config
.entries()
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options, global.bin)))
const name = () => resolve().pipe(Effect.map(ShellSelect.name)) const name = () => resolve().pipe(Effect.map(ShellSelect.name))
+51 -36
View File
@@ -34,15 +34,19 @@ function stat(file: string) {
return statSync(file, { throwIfNoEntry: false }) ?? undefined return statSync(file, { throwIfNoEntry: false }) ?? undefined
} }
function full(file: string, options?: Options) { function findExecutable(name: string, bin?: string) {
return which(name, undefined, bin)
}
function full(file: string, options?: Options, bin?: string) {
if (process.platform !== "win32") return file if (process.platform !== "win32") return file
const shell = FSUtil.windowsPath(file) const shell = FSUtil.windowsPath(file)
if (path.win32.dirname(shell) !== ".") { if (path.win32.dirname(shell) !== ".") {
if (shell.startsWith("/") && name(shell) === "bash") return gitbash(options) || shell if (shell.startsWith("/") && name(shell) === "bash") return gitbash(options, bin) || shell
return shell return shell
} }
if (name(shell) === "bash") return gitbash(options) || which(shell) || shell if (name(shell) === "bash") return gitbash(options, bin) || findExecutable(shell, bin) || shell
return which(shell) || shell return findExecutable(shell, bin) || shell
} }
function meta(file: string) { function meta(file: string) {
@@ -57,21 +61,26 @@ function rooted(file: string) {
return path.isAbsolute(FSUtil.windowsPath(file)) return path.isAbsolute(FSUtil.windowsPath(file))
} }
function resolve(file: string, options?: Options) { function resolve(file: string, options?: Options, bin?: string) {
const shell = full(file, options) const shell = full(file, options, bin)
if (rooted(shell)) { if (rooted(shell)) {
if (stat(shell)?.isFile()) return shell if (stat(shell)?.isFile()) return shell
return return
} }
return which(shell) ?? undefined return findExecutable(shell, bin) ?? undefined
} }
function win(options?: Options) { function win(options?: Options, bin?: string) {
return Array.from( return Array.from(
new Set( new Set(
[which("pwsh"), which("powershell"), gitbash(options), process.env.COMSPEC || "cmd.exe"] [
findExecutable("pwsh", bin),
findExecutable("powershell", bin),
gitbash(options, bin),
process.env.COMSPEC || "cmd.exe",
]
.filter((item): item is string => Boolean(item)) .filter((item): item is string => Boolean(item))
.map((file) => full(file, options)), .map((file) => full(file, options, bin)),
), ),
) )
} }
@@ -82,27 +91,27 @@ async function unix() {
return ["/bin/bash", "/bin/zsh", "/bin/sh"] return ["/bin/bash", "/bin/zsh", "/bin/sh"]
} }
function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }) { function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }, bin?: string) {
if (file && (!opts?.acceptable || ok(file))) { if (file && (!opts?.acceptable || ok(file))) {
const shell = resolve(file, options) const shell = resolve(file, options, bin)
if (shell) return shell if (shell) return shell
} }
if (process.platform === "win32") return win(options)[0] if (process.platform === "win32") return win(options, bin)[0]
return fallback() return fallback(bin)
} }
export function gitbash(options?: Options) { export function gitbash(options?: Options, bin?: string) {
if (process.platform !== "win32") return if (process.platform !== "win32") return
if (options?.gitbash) return options.gitbash if (options?.gitbash) return options.gitbash
const git = which("git") const git = findExecutable("git", bin)
if (!git) return if (!git) return
const file = path.join(git, "..", "..", "bin", "bash.exe") const file = path.join(git, "..", "..", "bin", "bash.exe")
if (stat(file)?.size) return file if (stat(file)?.size) return file
} }
function fallback() { function fallback(bin?: string) {
if (process.platform === "darwin") return "/bin/zsh" if (process.platform === "darwin") return "/bin/zsh"
const bash = which("bash") const bash = findExecutable("bash", bin)
if (bash) return bash if (bash) return bash
return "/bin/sh" return "/bin/sh"
} }
@@ -120,12 +129,12 @@ export function ps(file: string) {
return meta(file)?.ps === true return meta(file)?.ps === true
} }
function info(file: string, options?: Options): Item { function info(file: string, options?: Options, bin?: string): Item {
const item = full(file, options) const item = full(file, options, bin)
const n = name(item) const n = name(item)
return { return {
path: item, path: item,
name: resolve(n, options) ? n : item, name: resolve(n, options, bin) ? n : item,
acceptable: ok(item), acceptable: ok(item),
} }
} }
@@ -139,30 +148,36 @@ export function args(file: string, command: string) {
return ["-c", command] return ["-c", command]
} }
let defaultPreferred: string | undefined let defaultPreferred: { bin?: string; value: string } | undefined
let defaultAcceptable: string | undefined let defaultAcceptable: { bin?: string; value: string } | undefined
export function preferred(configShell?: string, options?: Options) { export function preferred(configShell?: string, options?: Options, bin?: string) {
if (configShell) return select(configShell, options) if (configShell) return select(configShell, options, undefined, bin)
if (options?.gitbash) return select(process.env.SHELL, options) if (options?.gitbash) return select(process.env.SHELL, options, undefined, bin)
defaultPreferred ??= select(process.env.SHELL) const cached = defaultPreferred
return defaultPreferred if (cached && cached.bin === bin) return cached.value
const value = select(process.env.SHELL, undefined, undefined, bin) ?? fallback(bin)
defaultPreferred = { bin, value }
return value
} }
preferred.reset = () => { preferred.reset = () => {
defaultPreferred = undefined defaultPreferred = undefined
} }
export function acceptable(configShell?: string, options?: Options) { export function acceptable(configShell?: string, options?: Options, bin?: string) {
if (configShell) return select(configShell, options, { acceptable: true }) if (configShell) return select(configShell, options, { acceptable: true }, bin)
if (options?.gitbash) return select(process.env.SHELL, options, { acceptable: true }) if (options?.gitbash) return select(process.env.SHELL, options, { acceptable: true }, bin)
defaultAcceptable ??= select(process.env.SHELL, undefined, { acceptable: true }) const cached = defaultAcceptable
return defaultAcceptable if (cached && cached.bin === bin) return cached.value
const value = select(process.env.SHELL, undefined, { acceptable: true }, bin) ?? fallback(bin)
defaultAcceptable = { bin, value }
return value
} }
acceptable.reset = () => { acceptable.reset = () => {
defaultAcceptable = undefined defaultAcceptable = undefined
} }
export async function list(options?: Options): Promise<Item[]> { export async function list(options?: Options, bin?: string): Promise<Item[]> {
const shells = process.platform === "win32" ? win(options) : await unix() const shells = process.platform === "win32" ? win(options, bin) : await unix()
return shells.filter((shell) => resolve(shell, options)).map((shell) => info(shell, options)) return shells.filter((shell) => resolve(shell, options, bin)).map((shell) => info(shell, options, bin))
} }
+23 -192
View File
@@ -2,17 +2,12 @@ export * as Skill from "./skill"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path" import path from "path"
import { Context, Effect, FiberMap, Layer, PubSub, Schema, Semaphore, Stream, Types } from "effect" import { Context, Effect, Layer, Types } from "effect"
import { Skill } from "@opencode-ai/schema/skill" import { Skill } from "@opencode-ai/schema/skill"
import { Agent } from "./agent" import { Agent } from "./agent"
import { ConfigMarkdown } from "./config/markdown"
import { Bus } from "./bus" import { Bus } from "./bus"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Permission } from "./permission" import { Permission } from "./permission"
import { AbsolutePath } from "./schema"
import { SkillDiscovery } from "./skill/discovery"
import { State } from "./state" import { State } from "./state"
import { Watcher } from "./filesystem/watcher"
export const DirectorySource = Skill.DirectorySource export const DirectorySource = Skill.DirectorySource
export type DirectorySource = Skill.DirectorySource export type DirectorySource = Skill.DirectorySource
@@ -57,38 +52,18 @@ export const toModelOutput = (skill: Info, files: ReadonlyArray<string>) => {
].join("\n") ].join("\n")
} }
const Frontmatter = Schema.Struct({
name: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
slash: Schema.Boolean.pipe(Schema.optional),
metadata: Schema.Unknown.pipe(Schema.optional),
})
const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter)
const metadataBoolean = (metadata: unknown, key: string) => {
if (metadata === undefined || metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
return undefined
}
const value = (metadata as { readonly [key: string]: unknown })[key]
if (typeof value === "boolean") return value
if (typeof value !== "string") return undefined
const normalized = value.trim().toLowerCase()
if (normalized === "true") return true
if (normalized === "false") return false
return undefined
}
export type Data = { export type Data = {
sources: Types.DeepMutable<Source>[] skills: Map<ID, Types.DeepMutable<Info>>
} }
export type Draft = { export type Draft = {
source: (source: Source) => void list: () => readonly Types.DeepMutable<Info>[]
list: () => readonly Source[] add: (skill: Info) => void
update: (id: string, update: (skill: Types.DeepMutable<Info>) => void) => void
remove: (id: string) => void
} }
export interface Interface extends State.Transformable<Draft> { export interface Interface extends State.Transformable<Draft> {
readonly sources: () => Effect.Effect<Source[]>
readonly list: () => Effect.Effect<Info[]> readonly list: () => Effect.Effect<Info[]>
} }
@@ -97,179 +72,35 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Sk
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const discovery = yield* SkillDiscovery.Service
const fs = yield* FSUtil.Service
const bus = yield* Bus.Service const bus = yield* Bus.Service
const watcher = yield* Watcher.Service
const cache = new Map<string, { skills: Info[]; paths: readonly string[] }>()
const watches = yield* FiberMap.make<string>()
const lock = Semaphore.makeUnsafe(1)
const changes = yield* PubSub.unbounded<string>()
const invalidate = Effect.fn("Skill.invalidateFromWatcher")(function* (file: string) {
const changed = yield* lock.withPermit(
Effect.gen(function* () {
const invalidated = Array.from(cache.entries()).filter(([, loaded]) =>
loaded.paths.some((item) => FSUtil.overlaps(item, file)),
)
if (invalidated.length === 0) return false
cache.clear()
yield* FiberMap.clear(watches)
yield* Effect.logInfo("skill cache invalidated", {
file,
sources: invalidated.map(([key]) => key),
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)),
})
return true
}),
)
if (!changed) return
yield* bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid)
})
yield* Stream.fromPubSub(changes).pipe(Stream.runForEach(invalidate), Effect.forkScoped({ startImmediately: true }))
const watch = Effect.fn("Skill.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
const target = path.resolve(directory)
const updates = yield* watcher.subscribe(
type === "file" ? { path: target, type: "file" } : { path: target, type: "directory" },
)
yield* FiberMap.run(
watches,
`${type}:${target}`,
updates.pipe(Stream.runForEach((update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid))),
{
onlyIfMissing: true,
startImmediately: true,
},
)
})
function firstMissing(target: string): Effect.Effect<string | undefined> {
const parent = path.dirname(target)
if (parent === target) return Effect.succeed(undefined)
return fs.isDir(parent).pipe(Effect.flatMap((exists) => (exists ? Effect.succeed(target) : firstMissing(parent))))
}
const watchDirectory: (directory: string) => Effect.Effect<string[]> = Effect.fn("Skill.watchDirectory")(function* (
directory: string,
) {
const target = path.resolve(directory)
const resolved = yield* fs.realPath(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (resolved) {
yield* watch(resolved, "directory")
if (resolved !== target) {
yield* watch(target, "file")
}
return resolved === target ? [target] : [target, resolved]
}
const missing = yield* firstMissing(target)
if (missing) yield* watch(missing, "file")
if (
yield* fs.realPath(directory).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
) {
if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`)
return yield* watchDirectory(directory)
}
return [target]
})
const state = State.create<Data, Draft>({ const state = State.create<Data, Draft>({
name: "skill", name: "skill",
initial: () => ({ sources: [] }), initial: () => ({ skills: new Map() }),
draft: (draft) => ({ draft: (draft) => ({
source: (source) => { list: () => Array.from(draft.skills.values()),
if (draft.sources.some((item) => Source.equals(item, source))) return add: (skill) => {
draft.sources.push(source as Types.DeepMutable<Source>) draft.skills.set(skill.id, { ...skill } as Types.DeepMutable<Info>)
},
update: (id, update) => {
const current = draft.skills.get(ID.make(id))
if (!current) return
update(current)
current.id = ID.make(id)
},
remove: (id) => {
draft.skills.delete(ID.make(id))
}, },
list: () => draft.sources as Source[],
}), }),
finalize: () => finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
lock
.withPermit(FiberMap.clear(watches).pipe(Effect.andThen(Effect.sync(() => cache.clear())), Effect.asVoid))
.pipe(Effect.andThen(bus.publish(Skill.Event.Updated, {})), Effect.asVoid),
})
const load = Effect.fn("Skill.load")(function* (source: Source) {
const skills: Info[] = []
if (source.type === "embedded") {
yield* Effect.logDebug("skill source loaded", {
source: Source.key(source),
type: source.type,
directories: [],
skills: [source.skill.id],
})
return { skills: [source.skill], paths: [] }
}
const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
const roots = (yield* Effect.forEach(directories, watchDirectory)).flat()
const paths = [...roots]
for (const directory of directories) {
const files = yield* fs
.scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
for (const filepath of files.toSorted()) {
const resolved = yield* fs.realPath(filepath).pipe(Effect.catch(() => Effect.succeed(filepath)))
if (!roots.some((root) => FSUtil.contains(root, resolved))) {
const external = path.dirname(resolved)
paths.push(external)
yield* watch(external, "directory")
}
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!content) continue
const markdown = ConfigMarkdown.parseOption(content)
if (!markdown) continue
const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined
if (!frontmatter) continue
const id =
path.dirname(filepath) === directory
? path.basename(filepath, ".md")
: path.basename(path.dirname(filepath))
skills.push({
id: ID.make(id),
name: Name.make(frontmatter.name ?? id),
description: frontmatter.description,
slash: metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash,
autoinvoke: metadataBoolean(frontmatter.metadata, "opencode/autoinvoke"),
location: AbsolutePath.make(filepath),
content: markdown.content,
})
}
}
yield* Effect.logDebug("skill source loaded", {
source: Source.key(source),
type: source.type,
directories,
skills: skills.map((skill) => skill.id),
})
return { skills, paths }
})
const list = Effect.fn("Skill.list")(function* () {
return yield* lock.withPermit(
Effect.gen(function* () {
const skills = new Map<ID, Info>()
for (const source of state.get().sources) {
const key = Source.key(source)
const loaded = cache.get(key) ?? (yield* load(source))
cache.set(key, loaded)
for (const skill of loaded.skills) skills.set(skill.id, skill)
}
return Array.from(skills.values())
}),
)
}) })
return Service.of({ return Service.of({
transform: state.transform, transform: state.transform,
reload: state.reload, reload: state.reload,
sources: Effect.fn("Skill.sources")(function* () { list: Effect.fn("Skill.list")(function* () {
return state.get().sources return Array.from(state.get().skills.values())
}), }),
list,
}) })
}), }),
) )
@@ -277,5 +108,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({ export const node = makeLocationNode({
service: Service, service: Service,
layer, layer,
deps: [SkillDiscovery.node, FSUtil.node, Bus.node, Watcher.node], deps: [Bus.node],
}) })
+1 -1
View File
@@ -21,7 +21,7 @@ Usage notes:
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label` - If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
export const Input = Schema.Struct({ export const Input = Schema.Struct({
questions: Schema.NonEmptyArray(Question.Prompt).annotate({ description: "Questions to ask" }), questions: Schema.Array(Question.Prompt).check(Schema.isNonEmpty()).annotate({ description: "Questions to ask" }),
}) })
export const Output = Schema.Struct({ export const Output = Schema.Struct({
+2 -3
View File
@@ -1,10 +1,9 @@
import whichPkg from "which" import whichPkg from "which"
import path from "path" import path from "path"
import { Global } from "@opencode-ai/util/global"
export function which(cmd: string, env?: NodeJS.ProcessEnv) { export function which(cmd: string, env?: NodeJS.ProcessEnv, bin?: string) {
const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? "" const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? ""
const full = base ? base + path.delimiter + Global.Path.bin : Global.Path.bin const full = base && bin ? base + path.delimiter + bin : base || bin
const result = whichPkg.sync(cmd, { const result = whichPkg.sync(cmd, {
nothrow: true, nothrow: true,
path: full, path: full,
+78 -3
View File
@@ -9,7 +9,7 @@ import { LLM, AIError, LLMEvent, Message, isContextOverflowFailure } from "@open
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route" import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
import { compileRequest } from "@opencode-ai/ai/route/client" import { compileRequest } from "@opencode-ai/ai/route/client"
import { expect } from "bun:test" import { expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const it = testEffect(AISDK.locationLayer) const it = testEffect(AISDK.locationLayer)
@@ -49,7 +49,10 @@ const client = LLMClient.layer.pipe(
Layer.provide( Layer.provide(
Layer.succeed( Layer.succeed(
RequestExecutor.Service, RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("Unexpected HTTP request") }), RequestExecutor.Service.of({
execute: () => Effect.die("Unexpected HTTP request"),
stream: () => Stream.die("Unexpected HTTP request"),
}),
), ),
), ),
) )
@@ -275,6 +278,73 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
}), }),
) )
it.effect("preserves tool result content in AI SDK prompts", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const resolved = yield* aisdk.model(model("test-ai-sdk"))
const prepared = yield* compileRequest(
LLM.request({
model: resolved,
messages: [
Message.tool({
id: "call_1",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "attachments" },
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "pixel.png" },
{
type: "file",
uri: "data:application/pdf;charset=utf-8;base64,JVBERg==",
mime: "application/pdf",
name: "document.pdf",
},
{ type: "file", uri: "data:audio/mpeg;base64,SUQz", mime: "audio/mpeg", name: "clip.mp3" },
{ type: "file", uri: "https://example.com/pixel.png", mime: "image/png" },
{ type: "file", uri: "https://example.com/document.pdf", mime: "application/pdf" },
],
},
}),
],
}),
)
expect(prepared.body.prompt).toEqual([
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call_1",
toolName: "read",
output: {
type: "content",
value: [
{ type: "text", text: "attachments" },
{ type: "image-data", data: "AAAA", mediaType: "image/png" },
{
type: "file-data",
data: "JVBERg==",
mediaType: "application/pdf",
filename: "document.pdf",
},
{ type: "file-data", data: "SUQz", mediaType: "audio/mpeg", filename: "clip.mp3" },
{ type: "image-url", url: "https://example.com/pixel.png" },
{ type: "file-url", url: "https://example.com/document.pdf" },
],
},
},
],
},
])
}),
)
it.effect("emits malformed AI SDK tool input without executing it", () => it.effect("emits malformed AI SDK tool input without executing it", () =>
Effect.gen(function* () { Effect.gen(function* () {
const aisdk = yield* AISDK.Service const aisdk = yield* AISDK.Service
@@ -475,7 +545,12 @@ it.effect("retries status-less AI SDK transport failures", () =>
isRetryable: true, isRetryable: true,
}), }),
) )
expect(error.reason).toMatchObject({ _tag: "Transport", kind: "AI_APICallError" }) expect(error.reason).toMatchObject({
_tag: "Transport",
transport: "http",
operation: "request",
code: "AI_APICallError",
})
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue() expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
expect("http" in error.reason ? error.reason.http?.request.url : undefined).toBe("https://api.example.com/chat") expect("http" in error.reason ? error.reason.http?.request.url : undefined).toBe("https://api.example.com/chat")
}), }),
@@ -0,0 +1,6 @@
---
name: first
description: First skill
---
# first
@@ -0,0 +1,6 @@
---
name: second
description: Second skill
---
# second
+35 -1
View File
@@ -5,8 +5,10 @@ import { describe, expect } from "bun:test"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect" import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Agent } from "@opencode-ai/core/agent" import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { Bus } from "@opencode-ai/core/bus" import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services" import { LocationServiceMap } from "@opencode-ai/core/location-services"
@@ -19,10 +21,19 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Fiber, Logger, Stream } from "effect" import { Effect, Fiber, Logger, Stream } from "effect"
import { Database } from "../../src/database/database" import { Database } from "../../src/database/database"
import { tmpdir } from "../fixture/tmpdir" import { tmpdir } from "../fixture/tmpdir"
import { tempGlobalLayer } from "../fixture/global"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
const it = testEffect( const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node])), AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
[Global.node, tempGlobalLayer],
]),
)
const staticIt = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
[ConfigPluginSource.node, ConfigPluginSource.empty],
[Global.node, tempGlobalLayer],
]),
) )
describe("PluginSupervisor config", () => { describe("PluginSupervisor config", () => {
@@ -157,6 +168,29 @@ describe("PluginSupervisor config", () => {
), ),
) )
staticIt.live("uses only internal and SDK plugins when the static source is wired", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
yield* sdk.register(EffectPlugin.define({ id: "static-sdk", effect: () => Effect.void }))
yield* withLocation(
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")] },
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
expect(ids).toContain("opencode.agent")
expect(ids).toContain("static-sdk")
expect(ids).not.toContain("config-promise-plugin")
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("directory"))).toBeUndefined()
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
}),
true,
)
}),
)
it.live("reloads an auto-discovered plugin when its file changes", () => it.live("reloads an auto-discovered plugin when its file changes", () =>
withLocation( withLocation(
undefined, undefined,
+4 -10
View File
@@ -46,9 +46,7 @@ describe("config plugin reloads", () => {
expect((yield* agents.get(Agent.ID.make("first")))?.description).toBe("First agent") expect((yield* agents.get(Agent.ID.make("first")))?.description).toBe("First agent")
expect((yield* commands.get("first"))?.description).toBe("First command") expect((yield* commands.get("first"))?.description).toBe("First command")
expect( expect((yield* skills.list()).some((skill) => skill.id === "first")).toBe(true)
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"),
).toBe(true)
expect((yield* references.list()).map((reference) => reference.name)).toEqual(["first"]) expect((yield* references.list()).map((reference) => reference.name)).toEqual(["first"])
expect(yield* catalog.provider.get(Provider.ID.make("first"))).toBeDefined() expect(yield* catalog.provider.get(Provider.ID.make("first"))).toBeDefined()
@@ -69,12 +67,8 @@ describe("config plugin reloads", () => {
}), }),
) )
expect( expect((yield* skills.list()).some((skill) => skill.id === "first")).toBe(false)
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"), expect((yield* skills.list()).some((skill) => skill.id === "second")).toBe(true)
).toBe(false)
expect(
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/second"),
).toBe(true)
}).pipe( }).pipe(
Effect.provide(Config.testLayer([config("first")])), Effect.provide(Config.testLayer([config("first")])),
Effect.provideService(Global.Service, Global.Service.of(Global.make())), Effect.provideService(Global.Service, Global.Service.of(Global.make())),
@@ -89,7 +83,7 @@ function config(name: string) {
info: decode({ info: decode({
agents: { [name]: { description: `${title(name)} agent`, mode: "subagent" } }, agents: { [name]: { description: `${title(name)} agent`, mode: "subagent" } },
commands: { [name]: { template: `${title(name)} command`, description: `${title(name)} command` } }, commands: { [name]: { template: `${title(name)} command`, description: `${title(name)} command` } },
skills: [`/skills/${name}`], skills: [path.join(import.meta.dir, "fixture", "skills", `${name}-source`)],
references: { [name]: `/references/${name}` }, references: { [name]: `/references/${name}` },
providers: { [name]: { models: { chat: { name: `${title(name)} model` } } } }, providers: { [name]: { models: { chat: { name: `${title(name)} model` } } } },
}), }),
+360 -68
View File
@@ -1,87 +1,379 @@
import fs from "fs/promises"
import path from "path" import path from "path"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer, Schema, Stream } from "effect" import { Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
import { AgentsDirectory, ClaudeDirectory, Directory, Document, Info } from "@opencode-ai/schema/config" import {
AgentsDirectory,
ClaudeDirectory,
Directory as ConfigDirectory,
Document,
type Entry,
Info,
} from "@opencode-ai/schema/config"
import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill" import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
import { SkillFile } from "@opencode-ai/core/config/plugin/skill-file"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Bus } from "@opencode-ai/core/bus"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { Skill } from "@opencode-ai/core/skill" import { Skill } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { tmpdir } from "../fixture/tmpdir"
import { location } from "../fixture/location" import { location } from "../fixture/location"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { host } from "../plugin/host" import { host } from "../plugin/host"
const it = testEffect(Layer.empty) const urls = new Map<string, AbsolutePath[]>()
const failedUrls = new Set<string>()
let pulls = 0
const discoveryLayer = Layer.succeed(
SkillDiscovery.Service,
SkillDiscovery.Service.of({
pull: (url) => {
pulls++
if (failedUrls.has(url)) return Effect.die(`failed to pull ${url}`)
return Effect.succeed(urls.get(url) ?? [])
},
}),
)
const watcherLayer = Watcher.testLayer
const it = testEffect(
Layer.mergeAll(
AppNodeBuilder.build(LayerNode.group([Skill.node, Bus.node, FSUtil.node])),
discoveryLayer,
watcherLayer,
),
)
const decode = Schema.decodeUnknownSync(Info) const decode = Schema.decodeUnknownSync(Info)
describe("ConfigSkillPlugin.Plugin", () => { function write(directory: string, name: string, description: string) {
it.effect("registers configured skill directories and URLs", () => return fs.writeFile(
Effect.gen(function* () { path.join(directory, name, "SKILL.md"),
const directory = AbsolutePath.make("/repo/packages/app") `---
const sources: Skill.Source[] = [] name: ${name}
const transform = Effect.fnUntraced(function* (update: (draft: Skill.Draft) => void | Effect.Effect<void>) { description: ${description}
const result = update({ ---
source: (source) => { # ${name}`,
sources.push(source) )
}, }
list: () => sources,
})
if (Effect.isEffect(result)) yield* result
const dispose = Effect.sync(() => {
sources.length = 0
})
yield* Effect.addFinalizer(() => dispose)
return { dispose }
})
yield* ConfigSkillPlugin.Plugin.effect( const startEntries = Effect.fnUntraced(function* (entries: Entry[], directory: string, home = directory) {
host({ const service = yield* Skill.Service
skill: { list: () => Effect.die("unused skill.list"), transform, reload: () => Effect.void }, yield* ConfigSkillPlugin.Plugin.effect(
}), host({
).pipe( skill: {
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home: "/home/test" })), list: () => Effect.die("unused skill.list"),
Effect.provideService(Location.Service, Location.Service.of(location({ directory }))), transform: service.transform,
Effect.provide( reload: service.reload,
Config.testLayer([ },
new ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/repo/.claude") }), }),
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/repo/.agents") }), ).pipe(
new Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }), Effect.provide(Config.testLayer(entries)),
new Document({ Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home })),
type: "document", Effect.provideService(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
info: decode({ )
skills: ["./skills", "~/shared-skills", "/opt/skills", "https://example.test/skills/"], return service
}), })
}),
]), const start = (skills: string[], directory: string) =>
startEntries(
[
new Document({
type: "document",
info: decode({ skills }),
}),
],
directory,
)
function emitAndWait(update: Watcher.Update) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
const bus = yield* Bus.Service
const deferred = yield* Deferred.make<void>()
const fiber = yield* bus.subscribe(Skill.Event.Updated).pipe(
Stream.runForEach(() => Deferred.succeed(deferred, undefined).pipe(Effect.asVoid)),
Effect.forkScoped,
)
yield* Effect.yieldNow
yield* watcher.emit(update)
yield* Deferred.await(deferred).pipe(Effect.timeout("2 seconds"))
yield* Fiber.interrupt(fiber)
})
}
describe("SkillFile.parse", () => {
it.effect("parses root and nested skill ids and metadata flags", () =>
Effect.sync(() => {
const directory = "/repo/skills"
expect(
SkillFile.parse(
directory,
"/repo/skills/manual/SKILL.md",
`---
name: Manual
description: Manual only
metadata:
opencode/slash: "true"
opencode/autoinvoke: false
---
# manual`,
), ),
) ).toEqual({
_tag: "Parsed",
expect(sources).toEqual([ skill: {
Skill.DirectorySource.make({ id: Skill.ID.make("manual"),
type: "directory", name: Skill.Name.make("Manual"),
path: AbsolutePath.make(path.join("/repo/.claude", "skills")), description: "Manual only",
}), slash: true,
Skill.DirectorySource.make({ autoinvoke: false,
type: "directory", location: AbsolutePath.make("/repo/skills/manual/SKILL.md"),
path: AbsolutePath.make(path.join("/repo/.agents", "skills")), content: "# manual",
}), },
Skill.DirectorySource.make({ })
type: "directory", expect(SkillFile.parse(directory, "/repo/skills/foo.md", "---\nslash: true\n---\n# foo")).toMatchObject({
path: AbsolutePath.make(path.join("/repo/.opencode", "skill")), _tag: "Parsed",
}), skill: { id: Skill.ID.make("foo") },
Skill.DirectorySource.make({ })
type: "directory", expect(
path: AbsolutePath.make(path.join("/repo/.opencode", "skills")), SkillFile.parse(directory, "/repo/skills/broken.md", "---\ndescription: foo: bar\nmetadata: [\n---\n# broken"),
}), ).toEqual({ _tag: "Skipped", reason: "markdown" })
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }), expect(SkillFile.parse(directory, "/repo/skills/broken.md", "---\nslash: nope\n---\n# broken")).toMatchObject({
Skill.DirectorySource.make({ _tag: "Skipped",
type: "directory", reason: "frontmatter",
path: AbsolutePath.make(path.join("/home/test", "shared-skills")), issue: expect.anything(),
}), })
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make("/opt/skills") }),
Skill.UrlSource.make({ type: "url", url: "https://example.test/skills/" }),
])
}), }),
) )
}) })
describe("ConfigSkillPlugin.Plugin", () => {
it.live("maps config entry types to skill directories", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const claude = path.join(tmp.path, "claude")
const agents = path.join(tmp.path, "agents")
const opencode = path.join(tmp.path, "opencode")
const home = path.join(tmp.path, "home")
const directory = path.join(tmp.path, "project")
const expected = [
path.join(claude, "skills"),
path.join(agents, "skills"),
path.join(opencode, "skill"),
path.join(opencode, "skills"),
path.join(home, "shared"),
path.join(directory, "relative"),
]
yield* Effect.promise(() => Promise.all(expected.map((item) => fs.mkdir(item, { recursive: true }))))
yield* startEntries(
[
new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(claude) }),
new AgentsDirectory({ type: "agents", path: AbsolutePath.make(agents) }),
new ConfigDirectory({ type: "directory", path: AbsolutePath.make(opencode) }),
new Document({ type: "document", info: decode({ skills: ["~/shared", "./relative"] }) }),
],
directory,
home,
)
const watcher = yield* Watcher.Test
expect(yield* watcher.subscriptions()).toEqual(expected.map((item) => ({ path: item, type: "directory" })))
}),
),
),
)
it.live("loads directory and URL sources with later-source precedence", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const first = path.join(tmp.path, "first")
const second = path.join(tmp.path, "second")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(first, "review"), { recursive: true })
await fs.mkdir(path.join(second, "review"), { recursive: true })
await write(first, "review", "First")
await write(second, "review", "Second")
})
pulls = 0
urls.set("https://example.test/skills/", [AbsolutePath.make(second)])
const skill = yield* start([first, "https://example.test/skills/"], tmp.path)
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Second")
expect(pulls).toBe(1)
}),
),
),
)
it.live("keeps directory skills when a URL source fails", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
await write(tmp.path, "review", "Available")
})
const url = "https://unreachable.example.test/skills/"
failedUrls.add(url)
const skill = yield* start([tmp.path, url], tmp.path)
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Available")
failedUrls.delete(url)
}),
),
),
)
it.live("rescans directory sources when watched files change", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
await write(tmp.path, "deploy", "Initial")
})
const skill = yield* start([tmp.path], tmp.path)
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Initial")
const deploy = path.join(tmp.path, "deploy", "SKILL.md")
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated"))
yield* emitAndWait({ type: "update", path: deploy })
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
await write(tmp.path, "review", "Review")
})
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "review", "SKILL.md") })
expect((yield* skill.list()).map((item) => item.id)).toEqual([
Skill.ID.make("deploy"),
Skill.ID.make("review"),
])
yield* Effect.promise(() => fs.rm(path.join(tmp.path, "review"), { recursive: true }))
yield* emitAndWait({ type: "delete", path: path.join(tmp.path, "review", "SKILL.md") })
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
}),
),
),
)
it.live("watches canonical directories behind symlinked skills", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "source")
const target = path.join(tmp.path, "target", "bro")
const file = path.join(target, "SKILL.md")
yield* Effect.promise(async () => {
await fs.mkdir(source, { recursive: true })
await fs.mkdir(target, { recursive: true })
await fs.writeFile(file, "---\nname: bro\ndescription: Initial\n---\n# bro")
await fs.symlink(target, path.join(source, "bro"), process.platform === "win32" ? "junction" : undefined)
})
const skill = yield* start([source], tmp.path)
const watcher = yield* Watcher.Test
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Initial")
expect(yield* watcher.subscriptions()).toContainEqual({ path: target, type: "directory" })
yield* Effect.promise(() => fs.writeFile(file, "---\nname: bro\ndescription: Updated\n---\n# bro"))
yield* emitAndWait({ type: "update", path: file })
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Updated")
}),
),
),
)
it.live("reloads symlinked sources when their target changes", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "source")
const first = path.join(tmp.path, "first")
const second = path.join(tmp.path, "second")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(first, "bro"), { recursive: true })
await fs.mkdir(path.join(second, "bro"), { recursive: true })
await write(first, "bro", "First")
await write(second, "bro", "Second")
await fs.symlink(first, source, process.platform === "win32" ? "junction" : undefined)
})
const skill = yield* start([source], tmp.path)
const watcher = yield* Watcher.Test
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("First")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: source, type: "file" },
])
yield* Effect.promise(async () => {
await fs.unlink(source)
await fs.symlink(second, source, process.platform === "win32" ? "junction" : undefined)
})
yield* emitAndWait({ type: "update", path: source })
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Second")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: source, type: "file" },
{ path: second, type: "directory" },
{ path: source, type: "file" },
])
}),
),
),
)
it.live("follows missing source directories as their parents appear", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "generated", "skills")
const skill = yield* start([source], tmp.path)
const watcher = yield* Watcher.Test
expect(yield* skill.list()).toEqual([])
expect(yield* watcher.subscriptions()).toEqual([{ path: path.join(tmp.path, "generated"), type: "file" }])
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "generated")))
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "generated") })
yield* Effect.promise(async () => {
await fs.mkdir(path.join(source, "deploy"), { recursive: true })
await write(source, "deploy", "Deploy")
})
yield* emitAndWait({ type: "create", path: source })
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
}),
),
),
)
})
+31 -5
View File
@@ -11,11 +11,19 @@ import { migrations } from "@opencode-ai/core/database/migration.gen"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import type { SqlClient } from "effect/unstable/sql/SqlClient" import type { SqlClient } from "effect/unstable/sql/SqlClient"
import { importLegacyCredentials } from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials" import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
import { Global } from "@opencode-ai/util/global"
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient>) => const run = <A, E>(
effect: Effect.Effect<A, E, SqlClient | Global.Service>,
global = Global.make({ data: path.join(process.cwd(), ".test-data") }),
) =>
Effect.runPromise( Effect.runPromise(
effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped), effect.pipe(
Effect.provideService(Global.Service, global),
Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })),
Effect.scoped,
),
) )
const makeDb = EffectDrizzleSqlite.makeWithDefaults() const makeDb = EffectDrizzleSqlite.makeWithDefaults()
@@ -31,7 +39,7 @@ describe("DatabaseMigration", () => {
Effect.scoped(Layer.build(layer)), Effect.scoped(Layer.build(layer)),
), ),
{ concurrency: "unbounded" }, { concurrency: "unbounded" },
), ).pipe(Effect.provideService(Global.Service, Global.make({ data: tmp.path }))),
) )
}) })
@@ -127,7 +135,8 @@ describe("DatabaseMigration", () => {
VALUES ('existing', 'anthropic', 'Existing', ${JSON.stringify({ type: "key", key: "current-key" })}, ${now}, ${now}) VALUES ('existing', 'anthropic', 'Existing', ${JSON.stringify({ type: "key", key: "current-key" })}, ${now}, ${now})
`) `)
yield* db.transaction((tx) => importLegacyCredentials(tx, source)) yield* db.run(sql`DELETE FROM migration WHERE id = ${legacyCredentialsMigration.id}`)
yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration])
expect(yield* db.all(sql`SELECT integration_id, label, value FROM credential ORDER BY integration_id`)).toEqual( expect(yield* db.all(sql`SELECT integration_id, label, value FROM credential ORDER BY integration_id`)).toEqual(
[ [
@@ -159,11 +168,28 @@ describe("DatabaseMigration", () => {
value: JSON.stringify(["https://example.com"]), value: JSON.stringify(["https://example.com"]),
}) })
}), }),
Global.make({ data: tmp.path }),
) )
expect(await Bun.file(source).text()).toBe(content) expect(await Bun.file(source).text()).toBe(content)
}) })
test("skips legacy credential import when the source file is absent", async () => {
await using tmp = await tmpdir()
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* DatabaseMigration.apply(db)
yield* db.run(sql`DELETE FROM migration WHERE id = ${legacyCredentialsMigration.id}`)
yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration])
expect(yield* db.all(sql`SELECT id FROM credential`)).toEqual([])
}),
Global.make({ data: tmp.path }),
)
})
test("rolls back a failed migration without recording it", async () => { test("rolls back a failed migration without recording it", async () => {
await run( await run(
Effect.gen(function* () { Effect.gen(function* () {
+27
View File
@@ -0,0 +1,27 @@
import path from "path"
import { Global } from "@opencode-ai/util/global"
import { Effect, Layer } from "effect"
import { tmpdir } from "./tmpdir"
export const tempGlobalLayer = Layer.unwrap(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.map((tmp) => {
const data = path.join(tmp.path, "data")
const cache = path.join(tmp.path, "cache")
return Global.layerWith({
home: path.join(tmp.path, "home"),
data,
cache,
config: path.join(tmp.path, "config"),
state: path.join(tmp.path, "state"),
tmp: path.join(tmp.path, "tmp"),
bin: path.join(cache, "bin"),
log: path.join(data, "log"),
repos: path.join(data, "repos"),
})
}),
),
)
-16
View File
@@ -1,16 +0,0 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { Global } from "@opencode-ai/util/global"
describe("global paths", () => {
test("tmp path is the canonical system temp directory", async () => {
expect(Global.Path.tmp).toBe(await fs.realpath(path.join(os.tmpdir(), "opencode")))
expect(Global.make().tmp).toBe(Global.Path.tmp)
})
test("tmp path is created on module load", async () => {
expect((await fs.stat(Global.Path.tmp)).isDirectory()).toBe(true)
})
})
+260 -175
View File
@@ -1,49 +1,124 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Bus } from "@opencode-ai/core/bus"
import { FSUtil } from "@opencode-ai/util/fs-util" import { ConfigInstructionPlugin } from "@opencode-ai/core/config/plugin/instruction"
import { Global } from "@opencode-ai/util/global" import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery" import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { Instructions } from "@opencode-ai/core/instructions"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { tempGlobalLayer } from "./fixture/global"
import { location } from "./fixture/location" import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { readInitial, readUpdate, state } from "./lib/instructions" import { readInitial, readUpdate, state } from "./lib/instructions"
import { testEffect } from "./lib/effect"
import { host } from "./plugin/host"
const it = testEffect(Layer.empty) const it = testEffect(Layer.empty)
const instructionLayer = (input: { const instructionLayer = (input: {
config: string config?: string
locationServiceLayer: Layer.Layer<Location.Service> locationServiceLayer: Layer.Layer<Location.Service>
filesystemLayer?: Layer.Layer<FSUtil.Service> filesystemLayer?: Layer.Layer<FSUtil.Service>
project?: boolean project?: boolean
}) => }) => {
AppNodeBuilder.build(InstructionDiscovery.node, [ const watcher = Watcher.testLayer
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })], return Layer.mergeAll(
[Global.node, Global.layerWith({ config: input.config })], AppNodeBuilder.build(
[Location.node, input.locationServiceLayer], LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]),
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []), [
]) [InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
[Global.node, input.config ? Global.layerWith({ config: input.config }) : tempGlobalLayer],
[Location.node, input.locationServiceLayer],
[Watcher.node, watcher],
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
],
),
watcher,
)
}
const start = Effect.fnUntraced(function* () {
yield* ConfigInstructionPlugin.Plugin.effect(host())
return yield* InstructionDiscovery.Service
})
const file = (path: string, content: string) =>
new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
function emitAndWait(update: Watcher.Update) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
const bus = yield* Bus.Service
const updated = yield* Deferred.make<void>()
const fiber = yield* bus.subscribe(InstructionDiscovery.Event.Updated).pipe(
Stream.runForEach(() => Deferred.succeed(updated, undefined).pipe(Effect.asVoid)),
Effect.forkScoped,
)
yield* Effect.yieldNow
yield* watcher.emit(update)
yield* Deferred.await(updated).pipe(Effect.timeout("2 seconds"))
yield* Fiber.interrupt(fiber)
})
}
describe("InstructionDiscovery", () => { describe("InstructionDiscovery", () => {
it.live("loads global and upward project AGENTS.md files as one aggregate context", () => it.effect("stores ordered values with last-write-wins precedence", () =>
Effect.gen(function* () {
const discovery = yield* InstructionDiscovery.Service
yield* discovery.transform((draft) => {
draft.add(file("/repo/AGENTS.md", "first"))
draft.add(file("/repo/packages/AGENTS.md", "package"))
draft.add(file("/repo/AGENTS.md", "last"))
draft.update("/repo/packages/AGENTS.md", (current) => {
current.content = "updated"
current.path = AbsolutePath.make("/ignored")
})
draft.remove("/missing")
})
expect(yield* discovery.list()).toEqual([
file("/repo/AGENTS.md", "last"),
file("/repo/packages/AGENTS.md", "updated"),
])
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
)
it.effect("preserves admitted values while the source is unavailable", () =>
Effect.gen(function* () {
const discovery = yield* InstructionDiscovery.Service
yield* discovery.transform((draft) => draft.unavailable())
expect(
(yield* readUpdate(
yield* discovery.load(),
state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] }),
)).changed,
).toBe(false)
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
)
})
describe("ConfigInstructionPlugin.Plugin", () => {
it.live("loads global and upward project files and rescans them on change", () =>
Effect.acquireRelease( Effect.acquireRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe( ).pipe(
Effect.flatMap((tmp) => Effect.flatMap((tmp) => {
Effect.gen(function* () { const global = path.join(tmp.path, "global")
const global = path.join(tmp.path, "global") const project = path.join(tmp.path, "project")
const project = path.join(tmp.path, "project") const directory = path.join(project, "packages", "core")
const directory = path.join(project, "packages", "core") const outside = path.join(tmp.path, "AGENTS.md")
const outside = path.join(tmp.path, "AGENTS.md") const globalFile = path.join(global, "AGENTS.md")
const globalFile = path.join(global, "AGENTS.md") const projectFile = path.join(project, "AGENTS.md")
const projectFile = path.join(project, "AGENTS.md") const packageFile = path.join(directory, "AGENTS.md")
const packageFile = path.join(directory, "AGENTS.md") return Effect.gen(function* () {
yield* Effect.promise(async () => { yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true }) await fs.mkdir(global, { recursive: true })
await fs.mkdir(directory, { recursive: true }) await fs.mkdir(directory, { recursive: true })
@@ -53,25 +128,15 @@ describe("InstructionDiscovery", () => {
await fs.writeFile(packageFile, "package") await fs.writeFile(packageFile, "package")
}) })
const load = InstructionDiscovery.Service.pipe( const discovery = yield* start()
Effect.flatMap((service) => service.load()), const watcher = yield* Watcher.Test
Effect.provide( expect(yield* watcher.subscriptions()).toEqual([
instructionLayer({ { path: globalFile, type: "file" },
config: global, { path: packageFile, type: "file" },
locationServiceLayer: Layer.succeed( { path: path.join(project, "packages", "AGENTS.md"), type: "file" },
Location.Service, { path: projectFile, type: "file" },
Location.Service.of( ])
location( const initialized = yield* readInitial(yield* discovery.load())
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(project) },
),
),
),
}),
),
)
const initialized = yield* readInitial(yield* load)
expect(initialized.text).toBe( expect(initialized.text).toBe(
[ [
`Instructions from: ${globalFile}\nglobal`, `Instructions from: ${globalFile}\nglobal`,
@@ -82,13 +147,14 @@ describe("InstructionDiscovery", () => {
expect(initialized.text).not.toContain("outside") expect(initialized.text).not.toContain("outside")
yield* Effect.promise(() => fs.writeFile(packageFile, "changed")) yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
expect((yield* readUpdate(yield* load, initialized)).text).toContain( yield* emitAndWait({ type: "update", path: packageFile })
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toContain(
`Instructions from: ${packageFile}\nchanged`, `Instructions from: ${packageFile}\nchanged`,
) )
yield* Effect.promise(() => fs.rm(packageFile)) yield* Effect.promise(() => fs.rm(packageFile))
const partial = yield* readUpdate(yield* load, initialized) yield* emitAndWait({ type: "delete", path: packageFile })
expect(partial.text).toBe( expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
[ [
"These instructions replace all previously loaded ambient instructions.", "These instructions replace all previously loaded ambient instructions.",
`Instructions from: ${globalFile}\nglobal`, `Instructions from: ${globalFile}\nglobal`,
@@ -96,12 +162,30 @@ describe("InstructionDiscovery", () => {
].join("\n\n"), ].join("\n\n"),
) )
yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)])) yield* Effect.promise(() => fs.rm(globalFile))
expect((yield* readUpdate(yield* load, initialized)).text).toBe( yield* emitAndWait({ type: "delete", path: globalFile })
yield* Effect.promise(() => fs.rm(projectFile))
yield* emitAndWait({ type: "delete", path: projectFile })
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
"Previously loaded instructions no longer apply.", "Previously loaded instructions no longer apply.",
) )
}), }).pipe(
), Effect.provide(
instructionLayer({
config: global,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(project) },
),
),
),
}),
),
)
}),
), ),
) )
@@ -114,115 +198,150 @@ describe("InstructionDiscovery", () => {
Effect.gen(function* () { Effect.gen(function* () {
const file = path.join(tmp.path, "AGENTS.md") const file = path.join(tmp.path, "AGENTS.md")
yield* Effect.promise(() => fs.writeFile(file, "")) yield* Effect.promise(() => fs.writeFile(file, ""))
const context = yield* InstructionDiscovery.Service.pipe( const discovery = yield* start()
Effect.flatMap((service) => service.load()), expect((yield* readInitial(yield* discovery.load())).text).toBe(`Instructions from: ${file}\n`)
Effect.provide( }).pipe(
instructionLayer({ Effect.provide(
config: path.join(tmp.path, "global"), instructionLayer({
locationServiceLayer: Layer.succeed( config: path.join(tmp.path, "global"),
Location.Service, locationServiceLayer: Layer.succeed(
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })), Location.Service,
), Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
}), ),
), }),
) ),
),
expect((yield* readInitial(context)).text).toBe(`Instructions from: ${file}\n`)
}),
), ),
), ),
) )
it.effect("preserves admitted instructions while observation is unavailable", () => it.live("discovers a newly created instruction file in an intermediate directory", () =>
Effect.gen(function* () { Effect.acquireRelease(
const failingFS = Layer.effect( Effect.promise(() => tmpdir()),
FSUtil.Service, (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
FSUtil.Service.pipe( ).pipe(
Effect.map((fs) => Effect.flatMap((tmp) => {
FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }), const project = path.join(tmp.path, "project")
), const intermediate = path.join(project, "packages", "AGENTS.md")
), const directory = path.join(project, "packages", "core")
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) const projectFile = path.join(project, "AGENTS.md")
const context = yield* InstructionDiscovery.Service.pipe( return Effect.gen(function* () {
Effect.flatMap((service) => service.load()), yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
Effect.provide( yield* Effect.promise(() => fs.writeFile(projectFile, "project"))
instructionLayer({ const discovery = yield* start()
config: "/global", expect((yield* readInitial(yield* discovery.load())).text).toBe(`Instructions from: ${projectFile}\nproject`)
filesystemLayer: failingFS,
locationServiceLayer: Layer.succeed( yield* Effect.promise(() => fs.writeFile(intermediate, "intermediate"))
Location.Service, yield* emitAndWait({ type: "create", path: intermediate })
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
expect((yield* readInitial(yield* discovery.load())).text).toBe(
[`Instructions from: ${intermediate}\nintermediate`, `Instructions from: ${projectFile}\nproject`].join(
"\n\n",
), ),
}), )
), }).pipe(
) Effect.provide(
instructionLayer({
expect( config: path.join(tmp.path, "global"),
(yield* readUpdate(context, state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] }))) locationServiceLayer: Layer.succeed(
.changed, Location.Service,
).toBe(false) Location.Service.of(
}), location(
) { directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(project) },
it.effect("preserves admitted instructions when a discovered file disappears before read", () => ),
Effect.gen(function* () { ),
const file = AbsolutePath.make("/repo/AGENTS.md") ),
const racingFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) =>
FSUtil.Service.of({
...fs,
up: () => Effect.succeed([file]),
readFileStringSafe: () => Effect.succeed(undefined),
}), }),
), ),
), )
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) }),
const context = yield* InstructionDiscovery.Service.pipe( ),
Effect.flatMap((service) => service.load()),
Effect.provide(
instructionLayer({
config: "/global",
filesystemLayer: racingFS,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
),
}),
),
)
expect(
(yield* readUpdate(context, state({ "core/instructions": [{ path: file, content: "old" }] }))).changed,
).toBe(false)
}),
) )
it.effect("canonicalizes upward discovery boundaries", () => it.effect("isolates source failure without failing activation", () => {
const failingFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) =>
FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }),
),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
return Effect.gen(function* () {
const discovery = yield* start()
expect(
(yield* readUpdate(
yield* discovery.load(),
state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] }),
)).changed,
).toBe(false)
}).pipe(
Effect.provide(
instructionLayer({
filesystemLayer: failingFS,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
),
}),
),
)
})
it.effect("marks a discovered file that disappears before read as unavailable", () => {
const discovered = AbsolutePath.make("/repo/AGENTS.md")
const racingFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) =>
FSUtil.Service.of({
...fs,
up: () => Effect.succeed([discovered]),
readFileStringSafe: () => Effect.succeed(undefined),
}),
),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
return Effect.gen(function* () {
const discovery = yield* start()
expect(
(yield* readUpdate(
yield* discovery.load(),
state({ "core/instructions": [{ path: discovered, content: "old" }] }),
)).changed,
).toBe(false)
}).pipe(
Effect.provide(
instructionLayer({
filesystemLayer: racingFS,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
),
}),
),
)
})
it.effect("canonicalizes boundaries and honors project opt-out", () =>
Effect.gen(function* () { Effect.gen(function* () {
let observed: { targets: string[]; start: string; stop?: string } | undefined const observed: { values: { targets: string[]; start: string; stop?: string }[] } = { values: [] }
const observingFS = Layer.effect( const observingFS = Layer.effect(
FSUtil.Service, FSUtil.Service,
FSUtil.Service.pipe( FSUtil.Service.pipe(
Effect.map((fs) => Effect.map((fs) =>
FSUtil.Service.of({ FSUtil.Service.of({
...fs, ...fs,
up: (options) => up: (options) => Effect.sync(() => (observed.values.push(options), [])),
Effect.sync(() => {
observed = options
return []
}),
}), }),
), ),
), ),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
yield* InstructionDiscovery.Service.pipe( yield* start().pipe(
Effect.flatMap((service) => service.load()),
Effect.provide( Effect.provide(
instructionLayer({ instructionLayer({
config: "/global",
filesystemLayer: observingFS, filesystemLayer: observingFS,
locationServiceLayer: Layer.succeed( locationServiceLayer: Layer.succeed(
Location.Service, Location.Service,
@@ -233,31 +352,11 @@ describe("InstructionDiscovery", () => {
}), }),
), ),
) )
yield* start().pipe(
expect(observed).toEqual({
targets: ["AGENTS.md"],
start: FSUtil.resolve("/repo"),
stop: FSUtil.resolve("/repo"),
})
}),
)
it.effect("honors the project instruction opt-out", () =>
Effect.gen(function* () {
let scanned = false
yield* InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide( Effect.provide(
instructionLayer({ instructionLayer({
config: "/global", filesystemLayer: observingFS,
project: false, project: false,
filesystemLayer: Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))),
locationServiceLayer: Layer.succeed( locationServiceLayer: Layer.succeed(
Location.Service, Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })), Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
@@ -265,25 +364,10 @@ describe("InstructionDiscovery", () => {
}), }),
), ),
) )
yield* start().pipe(
expect(scanned).toBe(false)
}),
)
it.effect("does not discover project instructions outside the canonical project root", () =>
Effect.gen(function* () {
let scanned = false
yield* InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide( Effect.provide(
instructionLayer({ instructionLayer({
config: "/global", filesystemLayer: observingFS,
filesystemLayer: Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))),
locationServiceLayer: Layer.succeed( locationServiceLayer: Layer.succeed(
Location.Service, Location.Service,
Location.Service.of( Location.Service.of(
@@ -297,7 +381,8 @@ describe("InstructionDiscovery", () => {
), ),
) )
expect(scanned).toBe(false) const repo = path.resolve("/repo")
expect(observed.values).toEqual([{ targets: ["AGENTS.md"], start: repo, stop: repo }])
}), }),
) )
}) })
@@ -1,4 +1,5 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import os from "os"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import * as TestClock from "effect/testing/TestClock" import * as TestClock from "effect/testing/TestClock"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -16,6 +17,7 @@ const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo")) const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
const timestamp = Date.parse("2026-06-03T12:00:00.000Z") const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
const sessionID = SessionSchema.ID.make("ses_builtin_test") const sessionID = SessionSchema.ID.make("ses_builtin_test")
const temporary = os.tmpdir()
const localDate = (time: number) => new Date(time).toDateString() const localDate = (time: number) => new Date(time).toDateString()
const locationLayer = Layer.succeed( const locationLayer = Layer.succeed(
Location.Service, Location.Service,
@@ -29,7 +31,7 @@ const locationLayer = Layer.succeed(
const it = testEffect( const it = testEffect(
AppNodeBuilder.build(InstructionBuiltIns.node, [ AppNodeBuilder.build(InstructionBuiltIns.node, [
[Location.node, locationLayer], [Location.node, locationLayer],
[Global.node, Global.layerWith({ config: "/global", tmp: "/temporary" })], [Global.node, Global.layerWith({ config: temporary, tmp: temporary })],
]), ]),
) )
@@ -49,7 +51,7 @@ describe("InstructionBuiltIns", () => {
` Workspace root folder: ${projectDirectory}`, ` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes", " Is directory a git repo: yes",
` Platform: ${process.platform}`, ` Platform: ${process.platform}`,
" Use /temporary for temporary work outside the workspace; it already exists and is pre-approved for external directory access.", ` Use ${temporary} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
"</env>", "</env>",
"", "",
`Today's date: ${localDate(timestamp)}`, `Today's date: ${localDate(timestamp)}`,
+10 -2
View File
@@ -9,6 +9,7 @@ import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { LocationServiceMap } from "@opencode-ai/core/location-services" import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { Plugin } from "@opencode-ai/core/plugin" import { Plugin } from "@opencode-ai/core/plugin"
@@ -21,6 +22,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session" import { Session } from "@opencode-ai/core/session"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { toolDefinitions, waitForTool } from "./lib/tool" import { toolDefinitions, waitForTool } from "./lib/tool"
import { Database } from "../src/database/database" import { Database } from "../src/database/database"
@@ -28,9 +30,15 @@ import { Bus } from "../src/bus"
import { Reference } from "../src/reference" import { Reference } from "../src/reference"
import { Tool } from "../src/tool" import { Tool } from "../src/tool"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]))) const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [
[Global.node, tempGlobalLayer],
]),
)
const itWithSdk = testEffect( const itWithSdk = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node])), AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
[Global.node, tempGlobalLayer],
]),
) )
describe("LocationServiceMap", () => { describe("LocationServiceMap", () => {
+91 -53
View File
@@ -1,19 +1,17 @@
import { describe, expect, beforeEach, afterAll, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Money } from "@opencode-ai/schema/money" import { Money } from "@opencode-ai/schema/money"
import { Effect, Layer, Ref } from "effect" import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform" import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global" import { KV } from "@opencode-ai/core/kv"
import { Model } from "@opencode-ai/core/model" import { Model } from "@opencode-ai/core/model"
import { ModelsDev } from "@opencode-ai/core/models-dev" import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Provider } from "@opencode-ai/core/provider" import { Provider } from "@opencode-ai/core/provider"
import { it } from "./lib/effect" import { it } from "./lib/effect"
import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises"
import path from "path"
const cacheFile = path.join(Global.Path.cache, "models.json") const cacheKey = "models-dev:catalog"
test("normalizes permissive interleaved values to compatibility", () => { test("normalizes permissive interleaved values to compatibility", () => {
expect(Model.compatibility("reasoning_text")).toEqual({ reasoningField: "reasoning_text" }) expect(Model.compatibility("reasoning_text")).toEqual({ reasoningField: "reasoning_text" })
@@ -164,7 +162,18 @@ const makeMockClient = (state: Ref.Ref<MockState>) =>
}), }),
) )
const buildLayer = (state: Ref.Ref<MockState>, options: ModelsDev.Options = { fetch: false }) => interface MockCache {
readonly values: Map<string, KV.Value>
}
const makeMockKV = (cache: MockCache) =>
Layer.mock(KV.Service, {
get: (key) => Effect.sync(() => cache.values.get(key)),
set: (key, value) => Effect.sync(() => cache.values.set(key, value)).pipe(Effect.asVoid),
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
})
const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: ModelsDev.Options = { fetch: false }) =>
// Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant, // Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant,
// and Effect.provide uses a process-global MemoMap by default — without fresh, // and Effect.provide uses a process-global MemoMap by default — without fresh,
// every test would reuse the cachedInvalidateWithTTL state from the first run. // every test would reuse the cachedInvalidateWithTTL state from the first run.
@@ -172,31 +181,29 @@ const buildLayer = (state: Ref.Ref<MockState>, options: ModelsDev.Options = { fe
AppNodeBuilder.build(ModelsDev.node, [ AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.configured(options)], [ModelsDev.node, ModelsDev.configured(options)],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))], [LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
[KV.node, makeMockKV(cache)],
]), ]),
) )
const writeCacheText = (text: string, mtimeMs?: number) => // Mirrors production KV backends whose writes die as defects (e.g. Durable
Effect.promise(async () => { // Object SQLite rejecting values over its 2 MB cap with EffectDrizzleQueryError).
await mkdir(Global.Path.cache, { recursive: true }) const makeFailingWriteKV = (cache: MockCache) =>
await writeFile(cacheFile, text) Layer.mock(KV.Service, {
if (mtimeMs !== undefined) { get: (key) => Effect.sync(() => cache.values.get(key)),
const t = mtimeMs / 1000 set: () => Effect.die(new Error('Failed query: insert into "kv"')),
await utimes(cacheFile, t, t) remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
}
}) })
const writeCache = (data: object, mtimeMs?: number) => writeCacheText(JSON.stringify(data), mtimeMs) const makeCache = (): MockCache => ({ values: new Map() })
const provided = <A, E>(state: Ref.Ref<MockState>, eff: Effect.Effect<A, E, ModelsDev.Service>) => const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
eff.pipe(Effect.provide(buildLayer(state))) cache.values.set(cacheKey, { updatedAt, body: text })
beforeEach(async () => { const writeCache = (cache: MockCache, data: object, updatedAt?: number) =>
await rm(cacheFile, { force: true }) writeCacheText(cache, JSON.stringify(data), updatedAt)
})
afterAll(async () => { const provided = <A, E>(state: Ref.Ref<MockState>, cache: MockCache, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
await rm(cacheFile, { force: true }) eff.pipe(Effect.provide(buildLayer(state, cache)))
})
const initialState: MockState = { const initialState: MockState = {
body: JSON.stringify(fixture), body: JSON.stringify(fixture),
@@ -205,12 +212,14 @@ const initialState: MockState = {
} }
describe("ModelsDev Service", () => { describe("ModelsDev Service", () => {
it.live("get() returns normalized snapshots from disk when cache file exists", () => it.live("get() returns normalized snapshots from KV when a cache entry exists", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* writeCache(fixture) const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make(initialState) const state = yield* Ref.make(initialState)
const result = yield* provided( const result = yield* provided(
state, state,
cache,
ModelsDev.Service.use((s) => s.get()), ModelsDev.Service.use((s) => s.get()),
) )
expect(result).toEqual(fixtureSnapshot) expect(result).toEqual(fixtureSnapshot)
@@ -219,11 +228,13 @@ describe("ModelsDev Service", () => {
}), }),
) )
it.live("get() returns empty catalog when disk empty, fetch disabled, and no bundled snapshot is injected", () => it.live("get() returns empty catalog when KV is empty, fetch disabled, and no bundled snapshot is injected", () =>
Effect.gen(function* () { Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState) const state = yield* Ref.make(initialState)
const result = yield* provided( const result = yield* provided(
state, state,
cache,
ModelsDev.Service.use((s) => s.get()), ModelsDev.Service.use((s) => s.get()),
) )
expect(result).toEqual([]) expect(result).toEqual([])
@@ -232,14 +243,34 @@ describe("ModelsDev Service", () => {
}), }),
) )
it.live("get() recovers from a corrupted cache file by fetching a fresh catalog", () => it.live("get() recovers from a corrupted KV entry by fetching a fresh catalog", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* writeCacheText("{") const cache = makeCache()
writeCacheText(cache, "{")
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const context = yield* Layer.build(buildLayer(state, { fetch: true })) const context = yield* Layer.build(buildLayer(state, cache, { fetch: true }))
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context)) const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
expect(result).toEqual(fixture2Snapshot) expect(result).toEqual(fixture2Snapshot)
expect(yield* Effect.promise(() => readFile(cacheFile, "utf8"))).toBe(JSON.stringify(fixture2)) expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
}),
)
it.live("get() still populates the catalog when the KV cache write fails", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const layer = Layer.fresh(
AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.configured({ fetch: true })],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
[KV.node, makeFailingWriteKV(cache)],
]),
)
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(layer))
expect(result).toEqual(fixture2Snapshot)
expect(cache.values.has(cacheKey)).toBe(false)
const final = yield* Ref.get(state) const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1) expect(final.calls.length).toBe(1)
}), }),
@@ -247,9 +278,10 @@ describe("ModelsDev Service", () => {
it.live("uses the default models URL when the configured URL is empty", () => it.live("uses the default models URL when the configured URL is empty", () =>
Effect.gen(function* () { Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState) const state = yield* Ref.make(initialState)
yield* ModelsDev.Service.use((service) => service.get()).pipe( yield* ModelsDev.Service.use((service) => service.get()).pipe(
Effect.provide(buildLayer(state, { url: "", fetch: true })), Effect.provide(buildLayer(state, cache, { url: "", fetch: true })),
) )
expect((yield* Ref.get(state)).calls[0]?.url).toBe("https://models.opencode.ai/api.json") expect((yield* Ref.get(state)).calls[0]?.url).toBe("https://models.opencode.ai/api.json")
}), }),
@@ -257,32 +289,31 @@ describe("ModelsDev Service", () => {
it.live("get() is single-flight under concurrent calls", () => it.live("get() is single-flight under concurrent calls", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* writeCache(fixture) const cache = makeCache()
const state = yield* Ref.make(initialState) const state = yield* Ref.make(initialState)
const results = yield* provided( const results = yield* Effect.gen(function* () {
state, const svc = yield* ModelsDev.Service
Effect.gen(function* () { return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], {
const svc = yield* ModelsDev.Service concurrency: "unbounded",
return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], { })
concurrency: "unbounded", }).pipe(Effect.provide(buildLayer(state, cache, { fetch: true })))
})
}),
)
for (const result of results) expect(result).toEqual(fixtureSnapshot) for (const result of results) expect(result).toEqual(fixtureSnapshot)
expect((yield* Ref.get(state)).calls.length).toBe(1)
}), }),
) )
it.live("get() caches across calls (later disk writes are ignored until invalidate)", () => it.live("get() caches across calls (later KV writes are ignored until invalidate)", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* writeCache(fixture) const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make(initialState) const state = yield* Ref.make(initialState)
const first = yield* provided( const first = yield* provided(
state, state,
cache,
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* ModelsDev.Service const svc = yield* ModelsDev.Service
const a = yield* svc.get() const a = yield* svc.get()
// mutate disk between calls — cache should mask the change writeCache(cache, fixture2)
yield* writeCache(fixture2)
const b = yield* svc.get() const b = yield* svc.get()
return { a, b } return { a, b }
}), }),
@@ -294,10 +325,12 @@ describe("ModelsDev Service", () => {
it.live("refresh(true) fetches via HttpClient and updates the cache", () => it.live("refresh(true) fetches via HttpClient and updates the cache", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* writeCache(fixture) const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const result = yield* provided( const result = yield* provided(
state, state,
cache,
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* ModelsDev.Service const svc = yield* ModelsDev.Service
const before = yield* svc.get() const before = yield* svc.get()
@@ -308,6 +341,7 @@ describe("ModelsDev Service", () => {
) )
expect(result.before).toEqual(fixtureSnapshot) expect(result.before).toEqual(fixtureSnapshot)
expect(result.after).toEqual(fixture2Snapshot) expect(result.after).toEqual(fixture2Snapshot)
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
const final = yield* Ref.get(state) const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1) expect(final.calls.length).toBe(1)
expect(final.calls[0].url).toContain("/api.json") expect(final.calls[0].url).toContain("/api.json")
@@ -315,13 +349,14 @@ describe("ModelsDev Service", () => {
}), }),
) )
it.live("refresh(false) skips fetch when on-disk file is fresh", () => it.live("refresh(false) skips fetch when the KV entry is fresh", () =>
Effect.gen(function* () { Effect.gen(function* () {
// Fresh: mtime within the 5-minute TTL. const cache = makeCache()
yield* writeCache(fixture, Date.now() - 1000) writeCache(cache, fixture, Date.now() - 1000)
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
yield* provided( yield* provided(
state, state,
cache,
ModelsDev.Service.use((s) => s.refresh(false)), ModelsDev.Service.use((s) => s.refresh(false)),
) )
const final = yield* Ref.get(state) const final = yield* Ref.get(state)
@@ -329,13 +364,14 @@ describe("ModelsDev Service", () => {
}), }),
) )
it.live("refresh(false) fetches when on-disk file is stale", () => it.live("refresh(false) fetches when the KV entry is stale", () =>
Effect.gen(function* () { Effect.gen(function* () {
// Stale: mtime 10 minutes ago, beyond the 5-minute TTL. const cache = makeCache()
yield* writeCache(fixture, Date.now() - 10 * 60 * 1000) writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const after = yield* provided( const after = yield* provided(
state, state,
cache,
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* ModelsDev.Service const svc = yield* ModelsDev.Service
yield* svc.refresh(false) yield* svc.refresh(false)
@@ -350,10 +386,12 @@ describe("ModelsDev Service", () => {
it.live("refresh swallows HTTP errors and leaves cache intact", () => it.live("refresh swallows HTTP errors and leaves cache intact", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* writeCache(fixture) const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" }) const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" })
const result = yield* provided( const result = yield* provided(
state, state,
cache,
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* ModelsDev.Service const svc = yield* ModelsDev.Service
yield* svc.refresh(true) yield* svc.refresh(true)
+48
View File
@@ -1,4 +1,5 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { ToolFailure } from "@opencode-ai/ai"
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect" import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect" import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { Config as ConfigSchema } from "@opencode-ai/schema/config"
@@ -395,4 +396,51 @@ describe("Plugin", () => {
}) })
}), }),
) )
it.effect("rejects tool execution when an execute.before hook fails", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const executed: unknown[] = []
const plugin = EffectPlugin.define({
id: "tool-hook-reject",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.tool
.transform((draft) =>
draft.add({
name: "echo",
options: { codemode: false },
description: "Echo",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) =>
Effect.sync(() => executed.push({ text })).pipe(Effect.as({ output: { text } })),
}),
)
.pipe(Effect.orDie)
yield* ctx.tool
.hook("execute.before", () => new ToolFailure({ message: "write disabled" }))
.pipe(Effect.asVoid)
}),
})
yield* plugins.activate([versioned(plugin)])
const toolSet = yield* registry.snapshot()
const failure = yield* toolSet
.execute({
sessionID: Session.ID.make("ses_hook_reject"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_hook_reject"),
call: { type: "tool-call", id: "call-hook-reject", name: "echo", input: { text: "original" } },
})
.pipe(Effect.flip)
expect(failure).toMatchObject({ _tag: "Tool.Error", message: "write disabled" })
expect(executed).toEqual([])
}),
)
}) })
+5 -1
View File
@@ -19,9 +19,11 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { Reference } from "@opencode-ai/core/reference" import { Reference } from "@opencode-ai/core/reference"
import { Skill } from "@opencode-ai/core/skill" import { Skill } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Tool } from "@opencode-ai/core/tool" import { Tool } from "@opencode-ai/core/tool"
import { WebSearch } from "@opencode-ai/core/websearch" import { WebSearch } from "@opencode-ai/core/websearch"
import { Effect, Layer, Stream } from "effect" import { Effect, Layer } from "effect"
import { tempLocationLayer } from "../fixture/location" import { tempLocationLayer } from "../fixture/location"
const npmLayer = Layer.succeed( const npmLayer = Layer.succeed(
@@ -53,8 +55,10 @@ export const PluginTestLayer = AppNodeBuilder.build(
PluginHooks.node, PluginHooks.node,
Reference.node, Reference.node,
Skill.node, Skill.node,
SkillDiscovery.node,
PluginHooks.node, PluginHooks.node,
Tool.node, Tool.node,
Watcher.node,
WebSearch.node, WebSearch.node,
]), ]),
[ [
@@ -215,6 +215,66 @@ describe("ModelsDevPlugin", () => {
}), }),
) )
it.effect("omits deprecated models from the catalog", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("acme")
const activeID = Model.ID.make("current")
const deprecatedID = Model.ID.make("legacy")
const model = {
modelID: activeID,
providerID,
name: "Current",
capabilities: { tools: true, input: [], output: [] },
variants: [],
time: { released: Date.parse("2026-01-01") },
cost: [],
status: "active",
enabled: true,
limit: { context: 128_000, output: 32_000 },
} satisfies Omit<Model.Info, "id">
const snapshots = [
{
info: {
id: providerID,
name: "Acme",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
},
environment: [],
models: [
{ id: activeID, ...model },
{
id: deprecatedID,
...model,
modelID: deprecatedID,
name: "Legacy",
status: "deprecated" as const,
},
],
},
] satisfies readonly ModelsDev.Snapshot[]
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
integration: integrationHost(integrations),
}),
).pipe(
Effect.provideService(
ModelsDev.Service,
ModelsDev.Service.of({
get: () => Effect.succeed(snapshots),
refresh: () => Effect.void,
}),
),
)
expect(yield* catalog.model.get(providerID, activeID)).toBeDefined()
expect(yield* catalog.model.get(providerID, deprecatedID)).toBeUndefined()
}),
)
it.effect("registers key methods for providers with environment variables", () => it.effect("registers key methods for providers with environment variables", () =>
Effect.gen(function* () { Effect.gen(function* () {
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
@@ -2,8 +2,6 @@ import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { Model } from "@opencode-ai/core/model" import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin" import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginHost } from "@opencode-ai/core/plugin/host"
@@ -18,6 +16,7 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () { const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin) const host = yield* PluginHost.make(plugin)
yield* CloudflareWorkersAIPlugin.effect(host) yield* CloudflareWorkersAIPlugin.effect(host)
}) })
@@ -104,13 +103,15 @@ describe("CloudflareWorkersAIPlugin", () => {
), ),
) )
it.effect("maps the environment account ID to the native endpoint", () => it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => yield* catalog.transform((catalog) =>
catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => { catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible") provider.package = Provider.aisdk("test-provider")
}), }),
) )
yield* addPlugin() yield* addPlugin()
@@ -118,10 +119,21 @@ describe("CloudflareWorkersAIPlugin", () => {
(yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-workers-ai")))?.methods, (yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-workers-ai")))?.methods,
).toContainEqual({ type: "key", label: "API key" }) ).toContainEqual({ type: "key", label: "API key" })
const provider = required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai"))) const provider = required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))
const sdk = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
modelID: Model.ID.make("@cf/model"),
package: provider.package,
settings: provider.settings,
}),
package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-workers-ai", headers: { custom: "header" } },
})
expect(provider).toMatchObject({ expect(provider).toMatchObject({
package: "@opencode-ai/ai/providers/cloudflare-workers-ai", package: "aisdk:test-provider",
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1" }, settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1" },
}) })
expect(sdk.sdk).toBeDefined()
}), }),
), ),
) )
@@ -181,72 +193,19 @@ describe("CloudflareWorkersAIPlugin", () => {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => yield* catalog.transform((catalog) =>
catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => { catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible") provider.package = Provider.aisdk("test-provider")
provider.settings = { ...provider.settings, accountId: "configured-acct" } provider.settings = { ...provider.settings, accountId: "configured-acct" }
}), }),
) )
yield* addPlugin() yield* addPlugin()
expect(required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))).toMatchObject({ expect(required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))).toMatchObject({
package: "@opencode-ai/ai/providers/cloudflare-workers-ai", package: "aisdk:test-provider",
settings: { settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1" },
accountId: "configured-acct",
baseURL: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1",
},
}) })
}), }),
), ),
) )
it.effect("passes the connected account ID to the native provider at runtime", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined }, () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("cloudflare-workers-ai")
yield* catalog.transform((draft) => {
draft.provider.update(providerID, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = {
accountId: "configured-acct",
baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
}
})
draft.model.update(providerID, Model.ID.make("@cf/model"), (model) => {
model.settings = {
accountId: "model-acct",
baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
}
})
})
yield* addPlugin()
const selected = required(yield* catalog.model.get(providerID, Model.ID.make("@cf/model")))
const { model } = yield* Effect.promise(() => import("@opencode-ai/ai/providers/cloudflare-workers-ai"))
const resolved = yield* ModelResolver.fromCatalogModel(
selected,
Credential.Key.make({
type: "key",
key: "secret",
configuration: { accountId: "connected-acct" },
}),
{ loadPackage: () => Effect.succeed({ model }) },
)
expect(required(yield* catalog.provider.get(providerID))).toMatchObject({
package: "@opencode-ai/ai/providers/cloudflare-workers-ai",
settings: { accountId: "configured-acct" },
})
expect(selected).toMatchObject({
package: "@opencode-ai/ai/providers/cloudflare-workers-ai",
settings: { accountId: "model-acct" },
})
expect(selected.settings).not.toHaveProperty("baseURL")
expect(resolved.route.endpoint.baseURL).toBe(
"https://api.cloudflare.com/client/v4/accounts/connected-acct/ai/v1",
)
}),
),
)
it.effect("uses env API key over auth or configured API key and keeps the Cloudflare User-Agent", () => it.effect("uses env API key over auth or configured API key and keeps the Cloudflare User-Agent", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
+16 -6
View File
@@ -543,11 +543,10 @@ describe("Session.create", () => {
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
) )
const targetDatabase = Database.layer({ path: path.join(tmp.path, "target.sqlite") })
const targetLayer = AppNodeBuilder.build( const targetLayer = AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]), LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]),
[ [
[Database.node, targetDatabase], [Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })],
[Bus.node, Bus.configured({ persist: true })], [Bus.node, Bus.configured({ persist: true })],
], ],
) )
@@ -648,14 +647,17 @@ describe("Session.create", () => {
it.effect("switches the selected agent through the durable Session event", () => it.effect("switches the selected agent through the durable Session event", () =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* Session.Service const session = yield* Session.Service
const created = yield* session.create({ location }) const created = yield* session.create({ location, agent: Agent.ID.make("build") })
yield* session.switchAgent({ sessionID: created.id, agent: Agent.ID.make("plan") }) yield* session.switchAgent({ sessionID: created.id, agent: Agent.ID.make("plan") })
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" }) expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
expect( expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect)), Array.from(yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect)),
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }]) ).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan", previous: "build" } }])
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
{ type: "agent-switched", agent: "plan", previous: "build" },
])
}), }),
) )
@@ -676,7 +678,12 @@ describe("Session.create", () => {
it.effect("switches the selected model through the durable Session event", () => it.effect("switches the selected model through the durable Session event", () =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* Session.Service const session = yield* Session.Service
const created = yield* session.create({ location }) const previous = Model.Ref.make({
id: Model.ID.make("haiku"),
providerID: Provider.ID.anthropic,
variant: Model.VariantID.make("default"),
})
const created = yield* session.create({ location, model: previous })
const model = Model.Ref.make({ const model = Model.Ref.make({
id: Model.ID.make("sonnet"), id: Model.ID.make("sonnet"),
providerID: Provider.ID.anthropic, providerID: Provider.ID.anthropic,
@@ -690,7 +697,10 @@ describe("Session.create", () => {
yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect), yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect),
) )
expect(bus).toMatchObject([{ type: "session.model.selected" }]) expect(bus).toMatchObject([{ type: "session.model.selected" }])
expect(bus[0]?.data).toEqual({ sessionID: created.id, model }) expect(bus[0]?.data).toEqual({ sessionID: created.id, model, previous })
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
{ type: "model-switched", model, previous },
])
}), }),
) )
+4 -2
View File
@@ -39,7 +39,9 @@ describe("toSessionError", () => {
) )
expect(toSessionError(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota") expect(toSessionError(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota")
expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter") expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter")
expect(toSessionError(llm(new TransportReason({ message: "transport" }))).type).toBe("provider.transport") expect(
toSessionError(llm(new TransportReason({ message: "transport", transport: "http", operation: "request" }))).type,
).toBe("provider.transport")
expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe( expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe(
"provider.internal", "provider.internal",
) )
@@ -111,7 +113,7 @@ describe("toSessionError", () => {
const eligible = [ const eligible = [
llm(new RateLimitReason({ message: "rate" })), llm(new RateLimitReason({ message: "rate" })),
llm(new ProviderInternalReason({ message: "internal", status: 500 })), llm(new ProviderInternalReason({ message: "internal", status: 500 })),
llm(new TransportReason({ message: "transport" })), llm(new TransportReason({ message: "transport", transport: "http", operation: "request" })),
] ]
const ineligible = [ const ineligible = [
llm(new AuthenticationReason({ message: "auth", kind: "invalid" })), llm(new AuthenticationReason({ message: "auth", kind: "invalid" })),
+1 -1
View File
@@ -31,7 +31,7 @@ describe("SessionExecution lifecycle", () => {
new AIError({ new AIError({
module: "test", module: "test",
method: "stream", method: "stream",
reason: new TransportReason({ message: "Disconnected" }), reason: new TransportReason({ message: "Disconnected", transport: "http", operation: "request" }),
}), }),
), ),
), ),
+4 -1
View File
@@ -99,7 +99,10 @@ const builtins = Layer.mock(InstructionBuiltIns.Service, {
}), }),
), ),
}) })
const discovery = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) }) const discovery = Layer.mock(InstructionDiscovery.Service, {
project: true,
load: () => Effect.succeed(Instructions.empty),
})
const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) }) const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) }) const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) }) const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
@@ -358,6 +358,7 @@ describe("SessionProjector", () => {
directory: "/project", directory: "/project",
title: "test", title: "test",
version: "test", version: "test",
agent: "plan",
model: previousModel, model: previousModel,
}) })
.run() .run()
@@ -459,6 +460,10 @@ describe("SessionProjector", () => {
text: "synthetic context", text: "synthetic context",
metadata: { source: "projector-test" }, metadata: { source: "projector-test" },
}) })
expect(messages.find((message) => message.type === "agent-switched")).toMatchObject({
agent: build,
previous: "plan",
})
expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel }) expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel })
expect(messages.find((message) => message.type === "shell")).toMatchObject({ expect(messages.find((message) => message.type === "shell")).toMatchObject({
command: "pwd", command: "pwd",
@@ -373,6 +373,103 @@ Recent work
]) ])
}) })
test("deduplicates provider media while preserving durable attachment references", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-duplicate-image"),
type: "user",
text: "[Image 1] [Image 1] [Image 2]",
files: [
FileAttachment.make({
data,
mime: "image/png",
source: { type: "inline" },
name: "image.png",
mention: { start: 0, end: 9, text: "[Image 1]" },
}),
FileAttachment.make({
data,
mime: "image/png",
source: { type: "inline" },
name: "image.png",
mention: { start: 10, end: 19, text: "[Image 1]" },
}),
FileAttachment.make({
data,
mime: "image/png",
source: { type: "inline" },
name: "image.png",
description: "alternate use",
mention: { start: 20, end: 29, text: "[Image 2]" },
}),
],
time: { created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "[Image 1] [Image 1] [Image 2]" },
{ type: "media", mediaType: "image/png", data, filename: "image.png" },
{
type: "media",
mediaType: "image/png",
data,
filename: "image.png",
metadata: { description: "alternate use" },
},
])
})
test("preserves provider media with distinct labels or URI sources", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-distinct-images"),
type: "user",
text: "[Image 1] [Image 2]",
files: [
FileAttachment.make({
data,
mime: "image/png",
source: { type: "inline" },
name: "image.png",
mention: { start: 0, end: 9, text: "[Image 1]" },
}),
FileAttachment.make({
data,
mime: "image/png",
source: { type: "inline" },
name: "image.png",
mention: { start: 10, end: 19, text: "[Image 2]" },
}),
FileAttachment.make({
data,
mime: "image/png",
source: { type: "uri", uri: "file:///project/image.png" },
name: "image.png",
mention: { start: 0, end: 9, text: "[Image 1]" },
}),
FileAttachment.make({
data,
mime: "image/png",
source: { type: "inline" },
name: "image.png",
}),
],
time: { created },
}),
],
model,
)
expect(messages[0]?.content.filter((part) => part.type === "media")).toHaveLength(4)
})
test("replays durable tool media into canonical tool messages without structured base64", () => { test("replays durable tool media into canonical tool messages without structured base64", () => {
const messages = toLLMMessages( const messages = toLLMMessages(
[ [
@@ -83,7 +83,10 @@ const models = Layer.mock(SessionRunnerModel.Service)({
), ),
}) })
const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) }) const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) })
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) }) const instructionContext = Layer.mock(InstructionDiscovery.Service, {
project: true,
load: () => Effect.succeed(Instructions.empty),
})
const skillInstructions = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) }) const skillInstructions = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const referenceInstructions = Layer.mock(ReferenceInstructions.Service, { const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
load: () => Effect.succeed(Instructions.empty), load: () => Effect.succeed(Instructions.empty),
+9 -2
View File
@@ -312,7 +312,10 @@ const systemContext = Layer.mock(InstructionBuiltIns.Service, {
}), }),
), ),
}) })
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) }) const instructionContext = Layer.mock(InstructionDiscovery.Service, {
project: true,
load: () => Effect.succeed(Instructions.empty),
})
const skillInstructions = Layer.mock(SkillInstructions.Service, { const skillInstructions = Layer.mock(SkillInstructions.Service, {
load: (agent) => load: (agent) =>
Effect.succeed( Effect.succeed(
@@ -512,7 +515,11 @@ const providerUnavailable = () =>
new AIError({ new AIError({
module: "test", module: "test",
method: "stream", method: "stream",
reason: new TransportReason({ message: "Provider unavailable" }), reason: new TransportReason({
message: "Provider unavailable",
transport: "http",
operation: "request",
}),
}) })
const incompleteStream = () => const incompleteStream = () =>
+71 -407
View File
@@ -1,438 +1,102 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Stream } from "effect" import { Deferred, Effect, Fiber, Stream } from "effect"
import { Agent } from "@opencode-ai/core/agent" import { Agent } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus" import { Bus } from "@opencode-ai/core/bus"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { Skill } from "@opencode-ai/core/skill" import { Skill } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const urls = new Map<string, AbsolutePath[]>() const it = testEffect(AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node])))
let pulls = 0
const discovery = Layer.succeed(
SkillDiscovery.Service,
SkillDiscovery.Service.of({
pull: (url) => {
pulls++
return Effect.succeed(urls.get(url) ?? [])
},
}),
)
const watcherLayer = Watcher.testLayer
const it = testEffect(
Layer.mergeAll(
AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node]), [
[SkillDiscovery.node, discovery],
[Watcher.node, watcherLayer],
]),
watcherLayer,
),
)
function write(directory: string, name: string, description: string) { const info = (id: string, description: string) =>
return fs.writeFile( Skill.Info.make({
path.join(directory, name, "SKILL.md"), id: Skill.ID.make(id),
`--- name: Skill.Name.make(id),
name: ${name} description,
description: ${description} location: AbsolutePath.make(`/skills/${id}/SKILL.md`),
--- content: `# ${id}`,
# ${name}`,
)
}
function waitForSkillUpdate() {
return Effect.gen(function* () {
const bus = yield* Bus.Service
const deferred = yield* Deferred.make<void>()
const fiber = yield* bus.subscribe(Skill.Event.Updated).pipe(
Stream.runForEach(() => Deferred.succeed(deferred, undefined).pipe(Effect.asVoid)),
Effect.forkScoped,
)
yield* Effect.yieldNow
return { deferred, fiber }
}) })
}
function expectSubscription(check: (input: Watcher.WatchInput) => boolean) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
expect((yield* watcher.subscriptions()).some(check)).toBe(true)
})
}
function emitAndWait(update: Watcher.Update) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
yield* Effect.acquireUseRelease(
waitForSkillUpdate(),
({ deferred }) => watcher.emit(update).pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
({ fiber }) => Fiber.interrupt(fiber),
)
})
}
describe("Skill", () => { describe("Skill", () => {
it.live("publishes updates when skill sources change", () => it.effect("registers values with last-write-wins precedence", () =>
Effect.gen(function* () { Effect.gen(function* () {
const skill = yield* Skill.Service const skill = yield* Skill.Service
yield* skill.transform((draft) => {
draft.add(info("review", "First"))
draft.add(info("deploy", "Deploy"))
draft.add(info("review", "Second"))
expect(draft.list().map((item) => item.id)).toEqual([Skill.ID.make("review"), Skill.ID.make("deploy")])
})
yield* Effect.acquireUseRelease( expect(yield* skill.list()).toEqual([info("review", "Second"), info("deploy", "Deploy")])
waitForSkillUpdate(),
({ deferred }) =>
skill
.transform((editor) =>
editor.source({ type: "directory", path: AbsolutePath.make("/tmp/opencode-skills") }),
)
.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
({ fiber }) => Fiber.interrupt(fiber),
)
}), }),
) )
it.live("registers sources and resolves later source precedence", () => it.effect("updates and removes registered values", () =>
Effect.acquireRelease( Effect.gen(function* () {
Effect.promise(() => tmpdir()), const skill = yield* Skill.Service
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), yield* skill.transform((draft) => {
).pipe( draft.add(info("review", "Initial"))
Effect.flatMap((tmp) => draft.update("review", (value) => {
Effect.gen(function* () { value.description = "Updated"
const first = path.join(tmp.path, "first") value.id = Skill.ID.make("ignored")
const second = path.join(tmp.path, "second") })
yield* Effect.promise(async () => { draft.update("missing", () => Effect.die("unreachable"))
await fs.mkdir(path.join(first, "review"), { recursive: true }) draft.add(info("deploy", "Deploy"))
await fs.mkdir(path.join(second, "review"), { recursive: true }) draft.remove("deploy")
await write(first, "review", "First") })
await write(second, "review", "Second")
await fs.writeFile(path.join(first, "foo.md"), "---\nslash: true\n---\n# foo")
})
const skill = yield* Skill.Service expect(yield* skill.list()).toEqual([info("review", "Updated")])
const watcher = yield* Watcher.Test }),
yield* skill.transform((editor) => {
editor.source({ type: "directory", path: AbsolutePath.make(first) })
editor.source({ type: "directory", path: AbsolutePath.make(first) })
editor.source({ type: "directory", path: AbsolutePath.make(second) })
expect(editor.list()).toEqual([
{ type: "directory", path: AbsolutePath.make(first) },
{ type: "directory", path: AbsolutePath.make(second) },
])
})
expect(yield* skill.sources()).toEqual([
{ type: "directory", path: AbsolutePath.make(first) },
{ type: "directory", path: AbsolutePath.make(second) },
])
expect(yield* skill.list()).toEqual([
Skill.Info.make({
id: Skill.ID.make("foo"),
name: Skill.Name.make("foo"),
slash: true,
location: AbsolutePath.make(path.join(first, "foo.md")),
content: "# foo",
}),
{
id: Skill.ID.make("review"),
name: Skill.Name.make("review"),
description: "Second",
location: AbsolutePath.make(path.join(second, "review", "SKILL.md")),
content: "# review",
},
])
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: second, type: "directory" },
])
yield* Effect.promise(() => write(second, "review", "Updated Second"))
yield* emitAndWait({ type: "update", path: path.join(second, "review", "SKILL.md") })
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Updated Second")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: second, type: "directory" },
{ path: first, type: "directory" },
{ path: second, type: "directory" },
])
}),
),
),
) )
it.live("loads URL sources and filters skills for agents", () => it.effect("restores earlier values when an updating transform is disposed", () =>
Effect.acquireRelease( Effect.gen(function* () {
Effect.promise(() => tmpdir()), const skill = yield* Skill.Service
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), const original = info("review", "Initial")
).pipe( yield* skill.transform((draft) => draft.add(original))
Effect.flatMap((tmp) => const updated = yield* skill.transform((draft) =>
Effect.gen(function* () { draft.update("review", (value) => {
yield* Effect.promise(async () => { value.description = "Updated"
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
await write(tmp.path, "deploy", "Deploy production")
})
pulls = 0
urls.set("https://example.test/skills/", [AbsolutePath.make(tmp.path)])
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(Agent.ID.make("reviewer"), (agent) => {
agent.permissions.push({ action: "skill", resource: "deploy", effect: "deny" })
}),
)
const skill = yield* Skill.Service
yield* skill.transform((editor) => editor.source({ type: "url", url: "https://example.test/skills/" }))
expect((yield* skill.list()).map((item) => item.name)).toEqual([Skill.Name.make("deploy")])
expect((yield* skill.list()).map((item) => item.name)).toEqual([Skill.Name.make("deploy")])
expect(pulls).toBe(1)
expect(Skill.available(yield* skill.list(), (yield* agents.get(Agent.ID.make("reviewer")))!)).toEqual([])
}), }),
), )
),
expect((yield* skill.list())[0]?.description).toBe("Updated")
yield* updated.dispose
expect((yield* skill.list())[0]?.description).toBe("Initial")
expect(original.description).toBe("Initial")
}),
) )
it.live("parses opencode metadata flags from skill frontmatter", () => it.live("publishes updates after committed values are visible", () =>
Effect.acquireRelease( Effect.gen(function* () {
Effect.promise(() => tmpdir()), const skill = yield* Skill.Service
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), const bus = yield* Bus.Service
).pipe( const updated = yield* Deferred.make<Skill.Info[]>()
Effect.flatMap((tmp) => const fiber = yield* bus.subscribe(Skill.Event.Updated).pipe(
Effect.gen(function* () { Stream.runForEach(() => skill.list().pipe(Effect.flatMap((values) => Deferred.succeed(updated, values)))),
yield* Effect.promise(async () => { Effect.forkScoped,
await fs.mkdir(path.join(tmp.path, "manual"), { recursive: true }) )
await fs.writeFile( yield* Effect.yieldNow
path.join(tmp.path, "manual", "SKILL.md"),
`---
name: manual
description: Manual only
metadata:
opencode/slash: true
opencode/autoinvoke: false
---
# manual`,
)
})
const skill = yield* Skill.Service yield* skill.transform((draft) => draft.add(info("review", "Visible")))
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) })) expect(yield* Deferred.await(updated).pipe(Effect.timeout("1 second"))).toEqual([info("review", "Visible")])
yield* Fiber.interrupt(fiber)
expect(yield* skill.list()).toEqual([ }),
{
id: Skill.ID.make("manual"),
name: Skill.Name.make("manual"),
description: "Manual only",
slash: true,
autoinvoke: false,
location: AbsolutePath.make(path.join(tmp.path, "manual", "SKILL.md")),
content: "# manual",
},
])
}),
),
),
) )
it.live("clears cached skills when sources reload", () => it.effect("filters values by agent permissions", () =>
Effect.acquireRelease( Effect.gen(function* () {
Effect.promise(() => tmpdir()), const agents = yield* Agent.Service
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), yield* agents.transform((draft) =>
).pipe( draft.update(Agent.ID.make("reviewer"), (agent) => {
Effect.flatMap((tmp) => agent.permissions.push({ action: "skill", resource: "deploy", effect: "deny" })
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
await write(tmp.path, "deploy", "Initial deploy")
})
const skill = yield* Skill.Service
const watcher = yield* Watcher.Test
const bus = yield* Bus.Service
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Initial deploy")
expect(yield* watcher.subscriptions()).toEqual([{ path: tmp.path, type: "directory" }])
let refreshed: Skill.Info[] = []
const unsubscribe = yield* bus.listen((event) => {
if (event.type !== Skill.Event.Updated.type) return Effect.void
return skill.list().pipe(
Effect.tap((items) => Effect.sync(() => (refreshed = items))),
Effect.asVoid,
)
})
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
yield* skill.reload().pipe(Effect.timeout("1 second"))
yield* unsubscribe
expect(refreshed.find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
expect(yield* watcher.subscriptions()).toEqual([
{ path: tmp.path, type: "directory" },
{ path: tmp.path, type: "directory" },
])
}), }),
), )
), const agent = yield* agents.get(Agent.ID.make("reviewer"))
) expect(Skill.available([info("deploy", "Deploy")], agent!)).toEqual([])
}),
it.live("reloads project sources created after their missing parent", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "generated", "skills")
const file = path.join(source, "deploy", "SKILL.md")
const skill = yield* Skill.Service
const watcher = yield* Watcher.Test
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
expect(yield* skill.list()).toEqual([])
expect(yield* watcher.subscriptions()).toEqual([{ path: path.join(tmp.path, "generated"), type: "file" }])
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "generated")))
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "generated") })
expect(yield* skill.list()).toEqual([])
expect(yield* watcher.subscriptions()).toEqual([
{ path: path.join(tmp.path, "generated"), type: "file" },
{ path: source, type: "file" },
])
yield* Effect.promise(async () => {
await fs.mkdir(path.dirname(file), { recursive: true })
await write(source, "deploy", "Deploy production")
})
yield* emitAndWait({ type: "create", path: source })
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
expect(yield* watcher.subscriptions()).toEqual([
{ path: path.join(tmp.path, "generated"), type: "file" },
{ path: source, type: "file" },
{ path: source, type: "directory" },
])
}),
),
),
)
it.live("watches directory sources for added and changed skills", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
await write(tmp.path, "deploy", "Initial deploy")
})
const skill = yield* Skill.Service
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
yield* expectSubscription((input) => input.type === "directory" && input.path === tmp.path)
const deploy = path.join(tmp.path, "deploy", "SKILL.md")
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
yield* emitAndWait({ type: "update", path: deploy })
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
await write(tmp.path, "review", "Review changes")
})
const review = path.join(tmp.path, "review", "SKILL.md")
yield* emitAndWait({ type: "create", path: review })
expect((yield* skill.list()).map((item) => item.id)).toEqual([
Skill.ID.make("deploy"),
Skill.ID.make("review"),
])
yield* Effect.promise(() => fs.rm(path.join(tmp.path, "review"), { recursive: true }))
yield* emitAndWait({ type: "delete", path: review })
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
}),
),
),
)
it.live("watches canonical directories behind symlinked skills", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "source")
const target = path.join(tmp.path, "target", "bro")
const file = path.join(target, "SKILL.md")
yield* Effect.promise(async () => {
await fs.mkdir(source, { recursive: true })
await fs.mkdir(target, { recursive: true })
await fs.writeFile(file, "---\nname: bro\ndescription: Initial\n---\n# bro")
await fs.symlink(target, path.join(source, "bro"))
})
const skill = yield* Skill.Service
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Initial")
yield* expectSubscription((input) => input.type === "directory" && input.path === target)
yield* Effect.promise(() => fs.writeFile(file, "---\nname: bro\ndescription: Updated\n---\n# bro"))
yield* emitAndWait({ type: "update", path: file })
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Updated")
}),
),
),
)
it.live("invalidates symlinked sources when their target changes", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "source")
const first = path.join(tmp.path, "first")
const second = path.join(tmp.path, "second")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(first, "bro"), { recursive: true })
await fs.mkdir(path.join(second, "bro"), { recursive: true })
await write(first, "bro", "First")
await write(second, "bro", "Second")
await fs.symlink(first, source)
})
const skill = yield* Skill.Service
const watcher = yield* Watcher.Test
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("First")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: source, type: "file" },
])
yield* Effect.promise(async () => {
await fs.unlink(source)
await fs.symlink(second, source)
})
yield* emitAndWait({ type: "update", path: source })
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Second")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: source, type: "file" },
{ path: second, type: "directory" },
{ path: source, type: "file" },
])
}),
),
),
) )
}) })
+24
View File
@@ -89,6 +89,30 @@ const it = testEffect(
) )
describe("QuestionTool", () => { describe("QuestionTool", () => {
it.effect("emits one item schema for the nonempty questions array", () =>
Effect.gen(function* () {
captured = undefined
const registry = yield* Tool.Service
const definition = (yield* toolDefinitions(registry)).find((tool) => tool.name === QuestionTool.name)
expect(definition?.inputSchema).toHaveProperty("properties.questions.type", "array")
expect(definition?.inputSchema).toHaveProperty("properties.questions.minItems", 1)
expect(definition?.inputSchema).toHaveProperty("properties.questions.items")
expect(definition?.inputSchema).not.toHaveProperty("properties.questions.prefixItems")
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question-empty", name: QuestionTool.name, input: { questions: [] } },
}),
).toMatchObject({
status: "error",
error: { type: "tool.execution", message: expect.stringContaining("Invalid tool input") },
})
expect(capturedInput()).toBeUndefined()
}),
)
it.effect("omits a catalog-denied question and enforces its leaf permission", () => it.effect("omits a catalog-denied question and enforces its leaf permission", () =>
Effect.gen(function* () { Effect.gen(function* () {
captured = undefined captured = undefined
+2
View File
@@ -33,6 +33,7 @@ import { ShellTool } from "@opencode-ai/core/tool/plugin/shell"
import { ToolOutput } from "@opencode-ai/core/tool-output" import { ToolOutput } from "@opencode-ai/core/tool-output"
import { Tool } from "@opencode-ai/core/tool" import { Tool } from "@opencode-ai/core/tool"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, toolDefinitions, waitForTool } from "./lib/tool" import { toolIdentity, executeTool, toolDefinitions, waitForTool } from "./lib/tool"
@@ -138,6 +139,7 @@ const layer = AppNodeBuilder.build(
[ [
[SessionExecution.node, executionNode], [SessionExecution.node, executionNode],
[Permission.node, permission], [Permission.node, permission],
[Global.node, tempGlobalLayer],
], ],
) )
-1
View File
@@ -81,7 +81,6 @@ describe("SkillTool", () => {
Skill.Service.of({ Skill.Service.of({
transform: (_transform) => Effect.die("unused"), transform: (_transform) => Effect.die("unused"),
reload: () => Effect.die("unused"), reload: () => Effect.die("unused"),
sources: () => Effect.die("unused"),
list: () => Effect.succeed(current), list: () => Effect.succeed(current),
}), }),
) )
+6 -1
View File
@@ -4,6 +4,7 @@ import path from "path"
import { Money } from "@opencode-ai/schema/money" import { Money } from "@opencode-ai/schema/money"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
import { Bus } from "@opencode-ai/core/bus" import { Bus } from "@opencode-ai/core/bus"
@@ -26,6 +27,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { SubagentTool } from "@opencode-ai/core/tool/plugin/subagent" import { SubagentTool } from "@opencode-ai/core/tool/plugin/subagent"
import { Tool } from "@opencode-ai/core/tool" import { Tool } from "@opencode-ai/core/tool"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { executeTool, toolIdentity, waitForTool } from "./lib/tool" import { executeTool, toolIdentity, waitForTool } from "./lib/tool"
@@ -100,7 +102,10 @@ const layer = AppNodeBuilder.build(
PluginRuntime.providerNode, PluginRuntime.providerNode,
LocationServiceMap.node, LocationServiceMap.node,
]), ]),
[[SessionExecution.node, executionNode]], [
[SessionExecution.node, executionNode],
[Global.node, tempGlobalLayer],
],
) )
const it = testEffect(layer) const it = testEffect(layer)
+40 -5
View File
@@ -11,16 +11,21 @@ import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { Effect, Layer, Logger, Schedule, Schema, Scope } from "effect" import { Effect, Fiber, Layer, Logger, Schedule, Schema, Scope } from "effect"
import { eq, sql } from "drizzle-orm" import { eq, sql } from "drizzle-orm"
import type { SqlClient } from "effect/unstable/sql/SqlClient" import type { SqlClient } from "effect/unstable/sql/SqlClient"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import path from "path" import path from "path"
const makeDb = EffectDrizzleSqlite.makeWithDefaults() const makeDb = EffectDrizzleSqlite.makeWithDefaults()
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient | Scope.Scope>) => const run = <A, E>(effect: Effect.Effect<A, E, SqlClient | Scope.Scope | Global.Service>) =>
Effect.runPromise( Effect.runPromise(
Effect.scoped(effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })))), Effect.scoped(
effect.pipe(
Effect.provideService(Global.Service, Global.make({ data: path.join(process.cwd(), ".test-data") })),
Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })),
),
),
) )
const session = ( const session = (
@@ -772,7 +777,7 @@ describe("V1Migration database workflow", () => {
`) `)
}) })
const database = <A, E>(effect: Effect.Effect<A, E, Database.Service | Scope.Scope>) => const database = <A, E>(effect: Effect.Effect<A, E, Database.Service | Global.Service | Scope.Scope>) =>
run( run(
Effect.gen(function* () { Effect.gen(function* () {
const db = yield* makeDb const db = yield* makeDb
@@ -793,6 +798,35 @@ describe("V1Migration database workflow", () => {
) )
}) })
test("yields while clearing stale events in batches", async () => {
await database(
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('stale', 2500)`)
yield* db.run(sql`
WITH RECURSIVE rows(value) AS (
VALUES(1)
UNION ALL
SELECT value + 1 FROM rows WHERE value < 2500
)
INSERT INTO event (id, aggregate_id, seq, created, type, data)
SELECT printf('event_%04d', value), 'stale', value, 1, 'session.renamed.1', '{}'
FROM rows
`)
let yielded = false
const heartbeat = yield* Effect.yieldNow.pipe(
Effect.andThen(Effect.sync(() => (yielded = true))),
Effect.forkChild({ startImmediately: true }),
)
expect(yield* V1Migration.run()).toEqual({ status: "completed" })
expect(yielded).toBe(true)
yield* Fiber.join(heartbeat)
expect(yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM event`)).toEqual({ value: 0 })
}),
)
})
test("imports previous V2 sessions and messages as part of the migration", async () => { test("imports previous V2 sessions and messages as part of the migration", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const filename = path.join(tmp.path, "opencode-next.db") const filename = path.join(tmp.path, "opencode-next.db")
@@ -935,6 +969,7 @@ describe("V1Migration database workflow", () => {
await database( await database(
Effect.gen(function* () { Effect.gen(function* () {
const { db } = yield* Database.Service const { db } = yield* Database.Service
const global = yield* Global.Service
yield* db.run( yield* db.run(
sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES ('ses_orphan', 'missing-project', 'orphan', '/tmp/orphan', 'Orphan', '1', 1, 2)`, sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES ('ses_orphan', 'missing-project', 'orphan', '/tmp/orphan', 'Orphan', '1', 1, 2)`,
) )
@@ -944,7 +979,7 @@ describe("V1Migration database workflow", () => {
project_id: "global", project_id: "global",
}) })
expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({ expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({
worktree: path.parse(Global.Path.data).root, worktree: path.parse(global.data).root,
}) })
expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2'`)).toEqual({ expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2'`)).toEqual({
value: '{"phase":"completed"}', value: '{"phase":"completed"}',
@@ -57,35 +57,35 @@ test("keeps a hidden prod launcher for old Linux pins", async () => {
expect(desktop).toContain("NoDisplay=true") expect(desktop).toContain("NoDisplay=true")
}) })
test("bundles the CLI outside the dev app archive", async () => { for (const channel of ["dev", "beta"] as const) {
const previous = process.env.OPENCODE_CHANNEL test(`bundles the CLI outside the ${channel} app archive`, async () => {
process.env.OPENCODE_CHANNEL = "dev"
const module = await import("./electron-builder.config.ts?cli-resource")
const config = module.default as Configuration
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
else process.env.OPENCODE_CHANNEL = previous
expect(config.files).toContain("!resources/opencode-cli*")
expect(config.extraResources).toContainEqual({
from: "resources/",
to: "",
filter: ["opencode-cli*"],
})
})
for (const channel of ["beta", "prod"] as const) {
test(`does not bundle the CLI in ${channel} builds`, async () => {
const previous = process.env.OPENCODE_CHANNEL const previous = process.env.OPENCODE_CHANNEL
process.env.OPENCODE_CHANNEL = channel process.env.OPENCODE_CHANNEL = channel
const module = await import(`./electron-builder.config.ts?no-cli-resource=${channel}`) const module = await import(`./electron-builder.config.ts?cli-resource=${channel}`)
const config = module.default as Configuration const config = module.default as Configuration
if (previous === undefined) delete process.env.OPENCODE_CHANNEL if (previous === undefined) delete process.env.OPENCODE_CHANNEL
else process.env.OPENCODE_CHANNEL = previous else process.env.OPENCODE_CHANNEL = previous
expect(config.extraResources).not.toContainEqual({ expect(config.files).toContain("!resources/opencode-cli*")
expect(config.extraResources).toContainEqual({
from: "resources/", from: "resources/",
to: "", to: "",
filter: ["opencode-cli*"], filter: ["opencode-cli*"],
}) })
}) })
} }
test("does not bundle the CLI in prod builds", async () => {
const previous = process.env.OPENCODE_CHANNEL
process.env.OPENCODE_CHANNEL = "prod"
const module = await import("./electron-builder.config.ts?no-cli-resource=prod")
const config = module.default as Configuration
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
else process.env.OPENCODE_CHANNEL = previous
expect(config.extraResources).not.toContainEqual({
from: "resources/",
to: "",
filter: ["opencode-cli*"],
})
})
+1 -1
View File
@@ -57,7 +57,7 @@ const getBase = (appId: string): Configuration => ({
}, },
files: ["out/**/*", "resources/**/*", "!resources/opencode-cli*"], files: ["out/**/*", "resources/**/*", "!resources/opencode-cli*"],
extraResources: [ extraResources: [
...(channel === "dev" ...(channel !== "prod"
? [ ? [
{ {
from: "resources/", from: "resources/",
+1
View File
@@ -37,6 +37,7 @@
"@actions/artifact": "4.0.0", "@actions/artifact": "4.0.0",
"@lydell/node-pty": "catalog:", "@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:", "@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:", "@sentry/vite-plugin": "catalog:",
+2 -2
View File
@@ -1,9 +1,9 @@
import { $ } from "bun" import { $ } from "bun"
import * as path from "node:path" import * as path from "node:path"
import { RUST_TARGET } from "./utils" import { CLI_TARGET } from "./utils"
if (!RUST_TARGET) throw new Error("RUST_TARGET not defined") if (!CLI_TARGET) throw new Error("OPENCODE_CLI_TARGET not defined")
const BUNDLE_DIR = "dist" const BUNDLE_DIR = "dist"
const BUNDLES_OUT_DIR = path.join(process.cwd(), "dist/bundles") const BUNDLES_OUT_DIR = path.join(process.cwd(), "dist/bundles")
+1
View File
@@ -8,3 +8,4 @@ await $`bun ./scripts/copy-icons.ts ${channel}`
await $`bun ./scripts/copy-metainfo.ts ${channel}` await $`bun ./scripts/copy-metainfo.ts ${channel}`
if (channel === "dev") await downloadCliToResources() if (channel === "dev") await downloadCliToResources()
if (channel === "beta") await downloadCliToResources("next")
+13 -13
View File
@@ -13,46 +13,46 @@ export function resolveChannel(): Channel {
return "dev" return "dev"
} }
export const CLI_BINARIES: Array<{ rustTarget: string; package: string; os: string; cpu: string }> = [ export const CLI_BINARIES: Array<{ target: string; package: string; os: string; cpu: string }> = [
{ {
rustTarget: "aarch64-apple-darwin", target: "aarch64-apple-darwin",
package: "@opencode-ai/cli-darwin-arm64", package: "@opencode-ai/cli-darwin-arm64",
os: "darwin", os: "darwin",
cpu: "arm64", cpu: "arm64",
}, },
{ {
rustTarget: "x86_64-apple-darwin", target: "x86_64-apple-darwin",
package: "@opencode-ai/cli-darwin-x64-baseline", package: "@opencode-ai/cli-darwin-x64-baseline",
os: "darwin", os: "darwin",
cpu: "x64", cpu: "x64",
}, },
{ {
rustTarget: "aarch64-pc-windows-msvc", target: "aarch64-pc-windows-msvc",
package: "@opencode-ai/cli-windows-arm64", package: "@opencode-ai/cli-windows-arm64",
os: "win32", os: "win32",
cpu: "arm64", cpu: "arm64",
}, },
{ {
rustTarget: "x86_64-pc-windows-msvc", target: "x86_64-pc-windows-msvc",
package: "@opencode-ai/cli-windows-x64-baseline", package: "@opencode-ai/cli-windows-x64-baseline",
os: "win32", os: "win32",
cpu: "x64", cpu: "x64",
}, },
{ {
rustTarget: "x86_64-unknown-linux-gnu", target: "x86_64-unknown-linux-gnu",
package: "@opencode-ai/cli-linux-x64-baseline", package: "@opencode-ai/cli-linux-x64-baseline",
os: "linux", os: "linux",
cpu: "x64", cpu: "x64",
}, },
{ {
rustTarget: "aarch64-unknown-linux-gnu", target: "aarch64-unknown-linux-gnu",
package: "@opencode-ai/cli-linux-arm64", package: "@opencode-ai/cli-linux-arm64",
os: "linux", os: "linux",
cpu: "arm64", cpu: "arm64",
}, },
] ]
export const RUST_TARGET = Bun.env.RUST_TARGET export const CLI_TARGET = Bun.env.OPENCODE_CLI_TARGET
function nativeTarget() { function nativeTarget() {
const { platform, arch } = process const { platform, arch } = process
@@ -62,19 +62,19 @@ function nativeTarget() {
throw new Error(`Unsupported platform: ${platform}/${arch}`) throw new Error(`Unsupported platform: ${platform}/${arch}`)
} }
export function getCurrentCli(target = RUST_TARGET ?? nativeTarget()) { export function getCurrentCli(target = CLI_TARGET ?? nativeTarget()) {
const binaryConfig = CLI_BINARIES.find((item) => item.rustTarget === target) const binaryConfig = CLI_BINARIES.find((item) => item.target === target)
if (!binaryConfig) throw new Error(`CLI configuration not available for target '${target}'`) if (!binaryConfig) throw new Error(`CLI configuration not available for target '${target}'`)
return binaryConfig return binaryConfig
} }
export async function downloadCliToResources() { export async function downloadCliToResources(version = CLI_VERSION) {
const cli = getCurrentCli() const cli = getCurrentCli()
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-")) const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
const dest = windowsify("resources/opencode-cli") const dest = windowsify("resources/opencode-cli")
try { try {
await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${CLI_VERSION}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}` await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${version}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
await copyFile( await copyFile(
join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"), join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"),
dest, dest,
@@ -88,7 +88,7 @@ export async function downloadCliToResources() {
} }
if (process.platform === "darwin") await $`codesign --force --sign - ${dest}` if (process.platform === "darwin") await $`codesign --force --sign - ${dest}`
console.log(`Copied ${cli.package} to ${dest}`) console.log(`Copied ${cli.package}@${version} to ${dest}`)
} }
export function windowsify(path: string) { export function windowsify(path: string) {
+23 -48
View File
@@ -1,3 +1,4 @@
import { Service } from "@opencode-ai/client/service"
import { execFile } from "node:child_process" import { execFile } from "node:child_process"
import { existsSync } from "node:fs" import { existsSync } from "node:fs"
import { chmod, copyFile, mkdir, rename, rm } from "node:fs/promises" import { chmod, copyFile, mkdir, rename, rm } from "node:fs/promises"
@@ -8,52 +9,34 @@ import { app } from "electron"
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
const root = dirname(fileURLToPath(import.meta.url)) const root = dirname(fileURLToPath(import.meta.url))
const stateHome = process.env.XDG_STATE_HOME
const desktopStateNames = ["ai.opencode.desktop.dev", "ai.opencode.desktop.beta", "ai.opencode.desktop"]
type Logger = { type Logger = {
log(message: string, meta?: Record<string, unknown>): void log(message: string, meta?: Record<string, unknown>): void
error(message: string, meta?: Record<string, unknown>): void error(message: string, meta?: Record<string, unknown>): void
} }
export async function startBackgroundCli(logger: Logger, shellStateHome?: string) { export async function startBackgroundCli(logger: Logger) {
const bundled = app.isPackaged const bundled = app.isPackaged
? join(process.resourcesPath, executableName()) ? join(process.resourcesPath, executableName())
: join(root, "../../resources", executableName()) : join(root, "../../resources", executableName())
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged }) logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
const version = await run(bundled, ["--version"], logger) const version = parseVersion(await run(bundled, ["--version"], logger))
const binary = app.isPackaged ? await installCli(bundled, version, logger) : bundled const binary = app.isPackaged ? await installCli(bundled, version, logger) : bundled
const service = await Service.ensure({
const candidates = [ version,
...new Set([stateHome, shellStateHome, ...desktopStateNames.map((name) => join(app.getPath("appData"), name))]), command: [binary, "serve", "--service"],
].filter((candidate) => candidate === undefined || existsSync(candidate)) onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
const discovered = await Promise.all(
candidates.map(async (candidate) => ({
stateHome: candidate,
url: serviceUrl(await run(binary, ["service", "status"], logger, { stateHome: candidate })),
})),
)
const found = discovered.find((candidate) => candidate.url !== undefined)
logger.log("v2 CLI background instance checked", {
detected: Boolean(found),
...endpoint(found?.url),
})
const daemonStateHome = found?.stateHome ?? stateHome
const url = await run(binary, ["service", "start"], logger, { stateHome: daemonStateHome })
const password = await run(binary, ["service", "get", "password"], logger, {
redact: true,
stateHome: daemonStateHome,
}) })
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
logger.log("v2 CLI background service ready", { logger.log("v2 CLI background service ready", {
existing: Boolean(found), username: service.auth.username,
username: "opencode", version,
...endpoint(url), ...endpoint(service.url),
}) })
return { return {
url, url: service.url,
username: "opencode", username: service.auth.username,
password, password: service.auth.password,
} }
} }
@@ -77,21 +60,13 @@ async function installCli(source: string, version: string, logger: Logger) {
return destination return destination
} }
async function run( async function run(binary: string, args: string[], logger: Logger) {
binary: string,
args: string[],
logger: Logger,
options: { redact?: boolean; stateHome?: string } = {},
) {
logger.log("v2 CLI command started", { binary, args }) logger.log("v2 CLI command started", { binary, args })
const env = { ...process.env } return execFileAsync(binary, args, { windowsHide: true }).then(
if (options.stateHome === undefined) delete env.XDG_STATE_HOME
else env.XDG_STATE_HOME = options.stateHome
return execFileAsync(binary, args, { env, windowsHide: true }).then(
(result) => { (result) => {
const stdout = result.stdout.trim() const stdout = result.stdout.trim()
const stderr = result.stderr.trim() const stderr = result.stderr.trim()
logger.log("v2 CLI command completed", { args, stdout: options.redact ? "[redacted]" : stdout, stderr }) logger.log("v2 CLI command completed", { args, stdout, stderr })
return stdout return stdout
}, },
(error: unknown) => { (error: unknown) => {
@@ -99,7 +74,7 @@ async function run(
logger.error("v2 CLI command failed", { logger.error("v2 CLI command failed", {
args, args,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
stdout: options.redact && output.stdout ? "[redacted]" : (output.stdout?.trim() ?? ""), stdout: output.stdout?.trim() ?? "",
stderr: output.stderr?.trim() ?? "", stderr: output.stderr?.trim() ?? "",
}) })
throw error throw error
@@ -107,11 +82,11 @@ async function run(
) )
} }
function serviceUrl(status: string) { function parseVersion(output: string) {
if (URL.canParse(status)) return status const marker = output.lastIndexOf(" v")
if (!status.startsWith("running ")) return const version = marker === -1 ? output : output.slice(marker + 2)
const url = status.slice("running ".length).trim() if (!version) throw new Error("V2 CLI did not provide a version")
return URL.canParse(url) ? url : undefined return version
} }
function endpoint(url: string | undefined) { function endpoint(url: string | undefined) {
+2 -2
View File
@@ -181,7 +181,7 @@ const main = Effect.gen(function* () {
return return
} }
const shellEnv = preferAppEnv(app.getPath("userData")) preferAppEnv()
app.on("second-instance", (_event: Event, argv: string[]) => { app.on("second-instance", (_event: Event, argv: string[]) => {
const urls = argv.filter((arg: string) => arg.startsWith("opencode://")) const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
@@ -310,7 +310,7 @@ const main = Effect.gen(function* () {
useEnvProxy() useEnvProxy()
logger.log("starting v2 background service") logger.log("starting v2 background service")
const sidecar = yield* Effect.promise(() => startBackgroundCli(logger, shellEnv?.XDG_STATE_HOME)) const sidecar = yield* Effect.promise(() => startBackgroundCli(logger))
yield* Deferred.succeed(serverReady, { yield* Deferred.succeed(serverReady, {
url: sidecar.url, url: sidecar.url,
username: sidecar.username, username: sidecar.username,
+2 -3
View File
@@ -17,17 +17,16 @@ export function setDefaultServerUrl(url: string | null) {
getStore().delete(DEFAULT_SERVER_URL_KEY) getStore().delete(DEFAULT_SERVER_URL_KEY)
} }
export function preferAppEnv(userDataPath: string) { export function preferAppEnv() {
const shell = process.platform === "win32" ? null : getUserShell() const shell = process.platform === "win32" ? null : getUserShell()
const shellEnv = shell ? loadShellEnv(shell, getLogger()) : null const shellEnv = shell ? loadShellEnv(shell, getLogger()) : null
if (!shellEnv?.XDG_STATE_HOME) delete process.env.XDG_STATE_HOME
Object.assign(process.env, { Object.assign(process.env, {
...shellEnv, ...shellEnv,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop", OPENCODE_CLIENT: "desktop",
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
}) })
return shellEnv
} }
export async function checkHealth(url: string, password?: string | null): Promise<boolean> { export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
+8 -7
View File
@@ -77,20 +77,21 @@ export function spatialPathSpans(points: readonly DiagramPoint[]): SpatialSpan[]
.sort(([left], [right]) => left - right) .sort(([left], [right]) => left - right)
.flatMap(([y, xs]) => { .flatMap(([y, xs]) => {
const sorted = [...xs].sort((left, right) => left - right) const sorted = [...xs].sort((left, right) => left - right)
const [first, ...rest] = sorted
if (first === undefined) return []
const spans: SpatialSpan[] = [] const spans: SpatialSpan[] = []
let start = sorted[0] let start = first
let end = start let end = first
if (start === undefined) return spans for (const x of rest) {
for (const x of sorted.slice(1)) { if (x === end + 1) {
if (x === end! + 1) {
end = x end = x
continue continue
} }
spans.push(normalizedSpan(y, start, end!)) spans.push(normalizedSpan(y, start, end))
start = x start = x
end = x end = x
} }
spans.push(normalizedSpan(y, start, end!)) spans.push(normalizedSpan(y, start, end))
return spans return spans
}) })
} }
+8 -4
View File
@@ -44,6 +44,10 @@ export interface MermaidMarkdownRendererOptions {
muted?: ColorInput muted?: ColorInput
warning?: ColorInput warning?: ColorInput
background?: ColorInput background?: ColorInput
request?: ColorInput
response?: ColorInput
note?: ColorInput
noteBackground?: ColorInput
} }
} }
@@ -131,12 +135,12 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
participant: color(colors.primary), participant: color(colors.primary),
lifeline: color(colors.muted), lifeline: color(colors.muted),
group: color(colors.secondary), group: color(colors.secondary),
request: color(colors.primary), request: color(colors.request ?? colors.primary),
response: color(colors.primary), response: color(colors.response ?? colors.primary),
fragment: color(colors.secondary), fragment: color(colors.secondary),
fragmentLabelBg: color(colors.background), fragmentLabelBg: color(colors.background),
note: color(colors.warning), note: color(colors.note ?? colors.warning),
noteBg: color(colors.background), noteBg: color(colors.noteBackground ?? colors.background),
}), }),
), ),
height: size.height, height: size.height,
+9
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { RGBA } from "@opentui/core" import { RGBA } from "@opentui/core"
import { blendColor } from "./core/color/style.js"
import { createOpenCodeDiagramPalette } from "./palette.js" import { createOpenCodeDiagramPalette } from "./palette.js"
type Rgb = readonly [number, number, number] type Rgb = readonly [number, number, number]
@@ -31,11 +32,15 @@ describe("OpenCode diagram palette", () => {
}>)("derives a controlled neutral ladder for a $name", ({ text, subdued, secondary, muted }) => { }>)("derives a controlled neutral ladder for a $name", ({ text, subdued, secondary, muted }) => {
const primary = rgb(text) const primary = rgb(text)
const info = RGBA.fromInts(40, 120, 220) const info = RGBA.fromInts(40, 120, 220)
const success = RGBA.fromInts(80, 180, 120)
const warning = RGBA.fromInts(220, 160, 80)
const background = RGBA.fromInts(10, 20, 30) const background = RGBA.fromInts(10, 20, 30)
const palette = createOpenCodeDiagramPalette({ const palette = createOpenCodeDiagramPalette({
text: primary, text: primary,
subdued: rgb(subdued), subdued: rgb(subdued),
info, info,
success,
warning,
background, background,
}) })
@@ -45,5 +50,9 @@ describe("OpenCode diagram palette", () => {
expect(palette.muted.equals(rgb(muted))).toBe(true) expect(palette.muted.equals(rgb(muted))).toBe(true)
expect(palette.warning).toBe(info) expect(palette.warning).toBe(info)
expect(palette.background).toBe(background) expect(palette.background).toBe(background)
expect(palette.request).toBe(success)
expect(palette.response).toBe(warning)
expect(palette.note).toBe(primary)
expect(palette.noteBackground.equals(blendColor(background, rgb(subdued), 0.25))).toBe(true)
}) })
}) })
+6
View File
@@ -5,6 +5,8 @@ export interface OpenCodeDiagramPaletteInput {
readonly text: RGBA readonly text: RGBA
readonly subdued: RGBA readonly subdued: RGBA
readonly info: RGBA readonly info: RGBA
readonly success: RGBA
readonly warning: RGBA
readonly background: RGBA readonly background: RGBA
} }
@@ -16,5 +18,9 @@ export function createOpenCodeDiagramPalette(input: OpenCodeDiagramPaletteInput)
muted: blendColor(input.text, input.subdued, 0.7), muted: blendColor(input.text, input.subdued, 0.7),
warning: input.info, warning: input.info,
background: input.background, background: input.background,
request: input.success,
response: input.warning,
note: input.text,
noteBackground: blendColor(input.background, input.subdued, 0.25),
} }
} }
+2
View File
@@ -12,6 +12,8 @@ export default Plugin.define({
text: context.theme.text.default, text: context.theme.text.default,
subdued: context.theme.text.subdued, subdued: context.theme.text.subdued,
info: context.theme.text.feedback.info.default, info: context.theme.text.feedback.info.default,
success: context.theme.text.feedback.success.default,
warning: context.theme.text.feedback.warning.default,
background: context.theme.background.default, background: context.theme.background.default,
}), }),
})), })),

Some files were not shown because too many files have changed in this diff Show More