mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-30 13:36:18 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e083b27ee6 | ||
|
|
8f4b45698a |
@@ -264,6 +264,24 @@ const layer = Layer.effect(
|
||||
const scope = yield* Scope.Scope
|
||||
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
|
||||
const commandAttempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, CommandAttemptEntry>())
|
||||
const environmentConnections = new Map<ID, { name: string; value: string }>()
|
||||
const resolveConnections = (
|
||||
entry: { readonly methods: readonly Method[] } | undefined,
|
||||
saved: readonly Credential.Info[],
|
||||
) => {
|
||||
const credentials = saved
|
||||
.map((credential) => ({
|
||||
type: "credential" as const,
|
||||
id: credential.id,
|
||||
label: credential.label,
|
||||
}))
|
||||
.toReversed()
|
||||
const env = (entry?.methods ?? [])
|
||||
.filter((method) => method.type === "env")
|
||||
.flatMap((method) => method.names.filter((name) => process.env[name]))
|
||||
.map((name) => ({ type: "env" as const, name }))
|
||||
return [...credentials, ...env]
|
||||
}
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "integration",
|
||||
initial: () => ({ integrations: new Map<ID, Entry>() }),
|
||||
@@ -326,24 +344,36 @@ const layer = Layer.effect(
|
||||
},
|
||||
},
|
||||
}),
|
||||
finalize: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
finalize: (draft) =>
|
||||
Effect.gen(function* () {
|
||||
const current = new Map(
|
||||
draft.list().flatMap((integration) => {
|
||||
const connection = resolveConnections({ methods: draft.method.list(integration.id) }, [])[0]
|
||||
return connection?.type === "env"
|
||||
? [[integration.id, { name: connection.name, value: process.env[connection.name] ?? "" }] as const]
|
||||
: []
|
||||
}),
|
||||
)
|
||||
const changed = Array.from(new Set([...environmentConnections.keys(), ...current.keys()])).filter((id) => {
|
||||
const previous = environmentConnections.get(id)
|
||||
const next = current.get(id)
|
||||
return previous?.name !== next?.name || previous?.value !== next?.value
|
||||
})
|
||||
environmentConnections.clear()
|
||||
current.forEach((connection, id) => environmentConnections.set(id, connection))
|
||||
yield* Effect.forEach(
|
||||
changed,
|
||||
(integrationID) =>
|
||||
Effect.gen(function* () {
|
||||
if ((yield* credentials.list(integrationID)).length > 0) return
|
||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
yield* bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid)
|
||||
}),
|
||||
})
|
||||
|
||||
const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => {
|
||||
const credentials = saved
|
||||
.map((credential) => ({
|
||||
type: "credential" as const,
|
||||
id: credential.id,
|
||||
label: credential.label,
|
||||
}))
|
||||
.toReversed()
|
||||
const env = (entry?.methods ?? [])
|
||||
.filter((method) => method.type === "env")
|
||||
.flatMap((method) => method.names.filter((name) => process.env[name]))
|
||||
.map((name) => ({ type: "env" as const, name }))
|
||||
return [...credentials, ...env]
|
||||
}
|
||||
|
||||
const project = (entry: Entry, connections: IntegrationConnection.Info[]): Info =>
|
||||
Info.make({
|
||||
id: entry.ref.id,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Clock, Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
|
||||
import { Cause, Clock, Duration, Effect, Exit, Fiber, Layer, Schema, Scope, Stream } from "effect"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -62,6 +62,203 @@ describe("Integration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes effective environment connection changes after state commits", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = process.env.OPENCODE_INTEGRATION_CONNECTION_TEST
|
||||
process.env.OPENCODE_INTEGRATION_CONNECTION_TEST = "secret"
|
||||
return previous
|
||||
}),
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const bus = yield* Bus.Service
|
||||
const integrationID = Integration.ID.make("environment-test")
|
||||
const observed: Array<{ id: Integration.ID; active: string | undefined }> = []
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.type !== Integration.Event.ConnectionUpdated.type) return
|
||||
const id = Schema.decodeUnknownSync(Integration.Event.ConnectionUpdated)(event).data.integrationID
|
||||
const active = yield* integrations.connection.active(id)
|
||||
observed.push({ id, active: active?.type === "env" ? active.name : active?.id })
|
||||
}),
|
||||
)
|
||||
const scope = yield* Scope.fork(yield* Scope.Scope)
|
||||
|
||||
yield* integrations
|
||||
.transform((draft) =>
|
||||
draft.method.update({
|
||||
integrationID,
|
||||
method: { type: "env", names: ["OPENCODE_INTEGRATION_CONNECTION_TEST"] },
|
||||
}),
|
||||
)
|
||||
.pipe(Scope.provide(scope))
|
||||
expect(observed).toEqual([{ id: integrationID, active: "OPENCODE_INTEGRATION_CONNECTION_TEST" }])
|
||||
|
||||
yield* integrations.transform((draft) =>
|
||||
draft.update(integrationID, (integration) => (integration.name = "Renamed")),
|
||||
)
|
||||
expect(observed).toHaveLength(1)
|
||||
|
||||
process.env.OPENCODE_INTEGRATION_CONNECTION_TEST = "rotated-secret"
|
||||
yield* integrations.transform((draft) =>
|
||||
draft.update(integrationID, (integration) => (integration.name = "Rotated")),
|
||||
)
|
||||
expect(observed).toEqual([
|
||||
{ id: integrationID, active: "OPENCODE_INTEGRATION_CONNECTION_TEST" },
|
||||
{ id: integrationID, active: "OPENCODE_INTEGRATION_CONNECTION_TEST" },
|
||||
])
|
||||
|
||||
const removal = yield* Scope.fork(yield* Scope.Scope)
|
||||
yield* integrations.transform((draft) => draft.remove(integrationID)).pipe(Scope.provide(removal))
|
||||
expect(observed).toEqual([
|
||||
{ id: integrationID, active: "OPENCODE_INTEGRATION_CONNECTION_TEST" },
|
||||
{ id: integrationID, active: "OPENCODE_INTEGRATION_CONNECTION_TEST" },
|
||||
{ id: integrationID, active: undefined },
|
||||
])
|
||||
yield* Scope.close(removal, Exit.void)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(observed).toEqual([
|
||||
{ id: integrationID, active: "OPENCODE_INTEGRATION_CONNECTION_TEST" },
|
||||
{ id: integrationID, active: "OPENCODE_INTEGRATION_CONNECTION_TEST" },
|
||||
{ id: integrationID, active: undefined },
|
||||
{ id: integrationID, active: "OPENCODE_INTEGRATION_CONNECTION_TEST" },
|
||||
{ id: integrationID, active: undefined },
|
||||
])
|
||||
yield* unsubscribe
|
||||
}),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
if (previous === undefined) delete process.env.OPENCODE_INTEGRATION_CONNECTION_TEST
|
||||
else process.env.OPENCODE_INTEGRATION_CONNECTION_TEST = previous
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("announces reordered environment connections only when the active variable changes", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = {
|
||||
first: process.env.OPENCODE_INTEGRATION_FIRST_TEST,
|
||||
second: process.env.OPENCODE_INTEGRATION_SECOND_TEST,
|
||||
}
|
||||
process.env.OPENCODE_INTEGRATION_FIRST_TEST = "first"
|
||||
process.env.OPENCODE_INTEGRATION_SECOND_TEST = "second"
|
||||
return previous
|
||||
}),
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const bus = yield* Bus.Service
|
||||
const integrationID = Integration.ID.make("ordered-test")
|
||||
const observed: string[] = []
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.type !== Integration.Event.ConnectionUpdated.type) return
|
||||
const active = yield* integrations.connection.active(integrationID)
|
||||
if (active?.type === "env") observed.push(active.name)
|
||||
}),
|
||||
)
|
||||
const first = yield* Scope.fork(yield* Scope.Scope)
|
||||
const second = yield* Scope.fork(yield* Scope.Scope)
|
||||
|
||||
yield* integrations
|
||||
.transform((draft) =>
|
||||
draft.method.update({
|
||||
integrationID,
|
||||
method: {
|
||||
type: "env",
|
||||
names: ["OPENCODE_INTEGRATION_FIRST_TEST", "OPENCODE_INTEGRATION_SECOND_TEST"],
|
||||
},
|
||||
}),
|
||||
)
|
||||
.pipe(Scope.provide(first))
|
||||
yield* integrations
|
||||
.transform((draft) =>
|
||||
draft.method.update({
|
||||
integrationID,
|
||||
method: {
|
||||
type: "env",
|
||||
names: ["OPENCODE_INTEGRATION_SECOND_TEST", "OPENCODE_INTEGRATION_FIRST_TEST"],
|
||||
},
|
||||
}),
|
||||
)
|
||||
.pipe(Scope.provide(second))
|
||||
yield* Scope.close(second, Exit.void)
|
||||
|
||||
expect(observed).toEqual([
|
||||
"OPENCODE_INTEGRATION_FIRST_TEST",
|
||||
"OPENCODE_INTEGRATION_SECOND_TEST",
|
||||
"OPENCODE_INTEGRATION_FIRST_TEST",
|
||||
])
|
||||
yield* unsubscribe
|
||||
}),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
if (previous.first === undefined) delete process.env.OPENCODE_INTEGRATION_FIRST_TEST
|
||||
else process.env.OPENCODE_INTEGRATION_FIRST_TEST = previous.first
|
||||
if (previous.second === undefined) delete process.env.OPENCODE_INTEGRATION_SECOND_TEST
|
||||
else process.env.OPENCODE_INTEGRATION_SECOND_TEST = previous.second
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("does not announce environment changes while a stored credential remains active", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = process.env.OPENCODE_INTEGRATION_PRIORITY_TEST
|
||||
process.env.OPENCODE_INTEGRATION_PRIORITY_TEST = "environment-secret"
|
||||
return previous
|
||||
}),
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const bus = yield* Bus.Service
|
||||
const integrationID = Integration.ID.make("priority-test")
|
||||
yield* credentials.create({
|
||||
integrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "stored-secret" }),
|
||||
})
|
||||
const observed: Integration.ID[] = []
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type === Integration.Event.ConnectionUpdated.type) {
|
||||
observed.push(Schema.decodeUnknownSync(Integration.Event.ConnectionUpdated)(event).data.integrationID)
|
||||
}
|
||||
}),
|
||||
)
|
||||
const scope = yield* Scope.fork(yield* Scope.Scope)
|
||||
|
||||
yield* integrations
|
||||
.transform((draft) =>
|
||||
draft.method.update({
|
||||
integrationID,
|
||||
method: { type: "env", names: ["OPENCODE_INTEGRATION_PRIORITY_TEST"] },
|
||||
}),
|
||||
)
|
||||
.pipe(Scope.provide(scope))
|
||||
expect(observed).toEqual([])
|
||||
expect((yield* integrations.connection.active(integrationID))?.type).toBe("credential")
|
||||
|
||||
process.env.OPENCODE_INTEGRATION_PRIORITY_TEST = "rotated-environment-secret"
|
||||
yield* integrations.transform((draft) =>
|
||||
draft.update(integrationID, (integration) => (integration.name = "Rotated")),
|
||||
)
|
||||
expect(observed).toEqual([])
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(observed).toEqual([])
|
||||
yield* unsubscribe
|
||||
}),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
if (previous === undefined) delete process.env.OPENCODE_INTEGRATION_PRIORITY_TEST
|
||||
else process.env.OPENCODE_INTEGRATION_PRIORITY_TEST = previous
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("reveals the previous registration when an override closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
@@ -18,11 +18,13 @@ import {
|
||||
Stream,
|
||||
} from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LocationServiceMap, type LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { LocationActivity } from "@opencode-ai/core/location-activity"
|
||||
@@ -53,6 +55,40 @@ const it = testEffect(
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
)
|
||||
const consoleRequests: Array<{ url: string; authorization: string | undefined }> = []
|
||||
const itWithConsole = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
[
|
||||
LayerNodePlatform.httpClient,
|
||||
Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Effect.sync(() => {
|
||||
consoleRequests.push({ url: request.url, authorization: request.headers.authorization })
|
||||
if (request.url !== "https://opencode.ai/console/api/config") {
|
||||
return HttpClientResponse.fromWeb(request, new Response("Not found", { status: 404 }))
|
||||
}
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
Response.json({
|
||||
config: {
|
||||
provider: {
|
||||
"console-openai-test": {
|
||||
name: "Console OpenAI",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
models: { "solstice-alpha": { name: "Solstice Alpha" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
)
|
||||
const itWithSdk = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
@@ -157,6 +193,54 @@ describe("LocationServiceMap", () => {
|
||||
),
|
||||
)
|
||||
|
||||
itWithConsole.live("discovers Console models during clean supervisor boot with only an environment service key", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = process.env.OPENCODE_API_KEY
|
||||
process.env.OPENCODE_API_KEY = "supervisor-service-account-secret"
|
||||
consoleRequests.length = 0
|
||||
return previous
|
||||
}),
|
||||
() =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const bus = yield* Bus.Service
|
||||
const context = yield* locations.contextEffect(
|
||||
Location.Ref.make({ directory: AbsolutePath.make(dir.path) }),
|
||||
)
|
||||
const catalog = yield* Catalog.Service.pipe(Effect.provide(context))
|
||||
const discovered = yield* bus.subscribe(Catalog.Event.Updated).pipe(
|
||||
Stream.mapEffect(() =>
|
||||
catalog.model.get(Provider.ID.make("console-openai-test"), Model.ID.make("solstice-alpha")),
|
||||
),
|
||||
Stream.filter((model): model is Model.Info => model !== undefined),
|
||||
Stream.runHead,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context))
|
||||
expect(consoleRequests).toContainEqual({
|
||||
url: "https://opencode.ai/console/api/config",
|
||||
authorization: "Bearer supervisor-service-account-secret",
|
||||
})
|
||||
expect((yield* Fiber.join(discovered).pipe(Effect.timeout("3 seconds"))).valueOrUndefined?.name).toBe(
|
||||
"Solstice Alpha",
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
if (previous === undefined) delete process.env.OPENCODE_API_KEY
|
||||
else process.env.OPENCODE_API_KEY = previous
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
itWithSdk.live("preserves embedded SDK plugins after Location eviction", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
@@ -92,6 +93,70 @@ describe("OpencodePlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("discovers Console models when a later plugin materializes service-account authentication", () =>
|
||||
withEnv({ OPENCODE_API_KEY: "service-account-secret" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const bus = yield* Bus.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const requests: Array<{ url: string; authorization: string | undefined }> = []
|
||||
const http = HttpClient.make((request) =>
|
||||
Effect.sync(() => {
|
||||
requests.push({ url: request.url, authorization: request.headers.authorization })
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
Response.json({
|
||||
config: {
|
||||
provider: {
|
||||
"console-openai-test": {
|
||||
name: "Console OpenAI",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
models: { "solstice-alpha": { name: "Solstice Alpha" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: OpencodePlugin.id,
|
||||
version: "1",
|
||||
effect: (host) =>
|
||||
OpencodePlugin.effect(host).pipe(
|
||||
Effect.provideService(Bus.Service, bus),
|
||||
Effect.provideService(HttpClient.HttpClient, http),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "later-sdk-integration",
|
||||
version: "1",
|
||||
effect: (host) =>
|
||||
host.integration.transform((draft) =>
|
||||
draft.method.update({
|
||||
integrationID: "opencode",
|
||||
method: { type: "env", names: ["OPENCODE_API_KEY"] },
|
||||
}),
|
||||
),
|
||||
},
|
||||
])
|
||||
|
||||
const model = yield* eventually(
|
||||
catalog.model.get(Provider.ID.make("console-openai-test"), Model.ID.make("solstice-alpha")),
|
||||
(current) => current !== undefined,
|
||||
)
|
||||
expect(model?.name).toBe("Solstice Alpha")
|
||||
expect(requests).toEqual([
|
||||
{ url: "https://opencode.ai/console/api/config", authorization: "Bearer service-account-secret" },
|
||||
])
|
||||
expect((yield* integrations.connection.active(Integration.ID.make("opencode")))?.type).toBe("env")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("uses a canonical custom server throughout device authorization", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
|
||||
Reference in New Issue
Block a user