mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f81d771e47 |
@@ -16,8 +16,9 @@ import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const messagePageSize = 200
|
||||
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
|
||||
const initialPageSize = 20
|
||||
const historyPageSize = 200
|
||||
const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => {
|
||||
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
|
||||
return [
|
||||
userMessage(undefined, { id, created: 1700000000000 + index * 2_000 }),
|
||||
@@ -25,7 +26,7 @@ const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
|
||||
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
||||
parentID: id,
|
||||
created: 1700000001000 + index * 2_000,
|
||||
completed: index < messagePageSize / 2,
|
||||
completed: index < initialPageSize,
|
||||
}),
|
||||
]
|
||||
}).flat()
|
||||
@@ -159,18 +160,21 @@ for (const scenario of scenarios) {
|
||||
await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
|
||||
const viewport = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await viewport.hover()
|
||||
const deadline = Date.now() + 30_000
|
||||
const deadline = Date.now() + 10_000
|
||||
while (requests.filter((request) => request.phase === "start").length < 2) {
|
||||
if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary")
|
||||
await page.mouse.wheel(0, -1_200)
|
||||
await page.mouse.wheel(0, -240)
|
||||
await page.waitForTimeout(20)
|
||||
}
|
||||
expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
|
||||
expect(sequence.slice(0, 3)).toEqual([
|
||||
"messages:start:latest",
|
||||
"messages:end:latest",
|
||||
`messages:start:${messages.at(-messagePageSize)!.info.id}`,
|
||||
`messages:start:${messages.at(-initialPageSize)!.info.id}`,
|
||||
])
|
||||
await expect(page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]')).toHaveCount(
|
||||
initialPageSize / 2,
|
||||
)
|
||||
await page.evaluate(() => {
|
||||
;(
|
||||
window as Window & {
|
||||
@@ -182,12 +186,15 @@ for (const scenario of scenarios) {
|
||||
expect(await visibleContentHidden(page)).toBe(false)
|
||||
const beforeHistory = await probeSamples(page)
|
||||
history.resolve()
|
||||
await expect
|
||||
.poll(() => page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]').count())
|
||||
.toBeGreaterThan(initialPageSize / 2)
|
||||
await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
|
||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||
await waitForProbeSamples(page, beforeHistory)
|
||||
expect(pages).toEqual([
|
||||
{ before: undefined, limit: messagePageSize },
|
||||
{ before: messages.at(-messagePageSize)!.info.id, limit: messagePageSize },
|
||||
{ before: undefined, limit: initialPageSize },
|
||||
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
|
||||
])
|
||||
expect(roots).toEqual([])
|
||||
|
||||
|
||||
@@ -222,7 +222,7 @@ function turn(index: number): Message[] {
|
||||
return [user, assistantMessage(targetID, index, user.info.id, parts)]
|
||||
}
|
||||
|
||||
const targetMessages = Array.from({ length: 101 }, (_, index) => turn(index)).flat()
|
||||
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
|
||||
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
||||
userMessage(sourceID, index + 1000, 120),
|
||||
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
|
||||
|
||||
@@ -727,7 +727,7 @@ function expectCompleteScroll(
|
||||
).toEqual([])
|
||||
expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length)
|
||||
expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length)
|
||||
expect(expectedPartIDs.length).toBe(465)
|
||||
expect(expectedPartIDs.length).toBe(331)
|
||||
}
|
||||
|
||||
async function selectHomeProject(page: Page, projectName: string) {
|
||||
|
||||
@@ -618,7 +618,7 @@ describe("server session", () => {
|
||||
await ctx.store.sync("root")
|
||||
|
||||
expect(ctx.get).toEqual([{ sessionID: "root" }])
|
||||
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 200, order: "desc" }])
|
||||
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
|
||||
expect(ctx.store.data.message.root).toEqual([])
|
||||
})
|
||||
|
||||
@@ -629,32 +629,8 @@ describe("server session", () => {
|
||||
ctx.store.invalidate()
|
||||
await ctx.store.sync("root")
|
||||
|
||||
expect(ctx.store.data.message.root).toEqual([])
|
||||
expect(ctx.get).toHaveLength(2)
|
||||
expect(ctx.messages).toEqual([
|
||||
{ sessionID: "root", limit: 200, order: "desc" },
|
||||
{ sessionID: "root", limit: 200, order: "desc" },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps a fixed page size after the local message cache exceeds the API limit", async () => {
|
||||
const client = messageClient(response(), response())
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
Array.from({ length: 428 }, (_, index) =>
|
||||
store.apply({
|
||||
type: "message.updated",
|
||||
properties: { info: userMessage(`message-${index}`, { time: { created: index } }) },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(store.data.message.child).toHaveLength(428)
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(client.requests).toEqual([
|
||||
{ sessionID: "child", limit: 200, order: "desc" },
|
||||
{ sessionID: "child", limit: 200, order: "desc" },
|
||||
])
|
||||
expect(ctx.messages).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("loads current session content through the current message API", async () => {
|
||||
@@ -679,7 +655,7 @@ describe("server session", () => {
|
||||
|
||||
await store.sync("root")
|
||||
|
||||
expect(requests).toEqual([{ sessionID: "root", limit: 200, order: "desc" }])
|
||||
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
|
||||
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
||||
expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
||||
})
|
||||
@@ -755,8 +731,8 @@ describe("server session", () => {
|
||||
await store.sync("root")
|
||||
|
||||
expect(requests).toEqual([
|
||||
{ sessionID: "root", limit: 200, order: "desc" },
|
||||
{ sessionID: "root", limit: 200, cursor: "older" },
|
||||
{ sessionID: "root", limit: 20, order: "desc" },
|
||||
{ sessionID: "root", limit: 20, cursor: "older" },
|
||||
])
|
||||
expect(store.data.message.root.map((message) => message.id)).toEqual([
|
||||
user.id,
|
||||
@@ -765,26 +741,6 @@ describe("server session", () => {
|
||||
expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"])
|
||||
})
|
||||
|
||||
test("loads older messages by cursor with the fixed page size", async () => {
|
||||
const older = userMessage("message-1")
|
||||
const latest = userMessage("message-2", { time: { created: 2 } })
|
||||
const client = messageClient(
|
||||
response([{ info: latest, parts: [] }], "older"),
|
||||
response([{ info: older, parts: [] }]),
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
|
||||
await store.history.loadMore("child")
|
||||
|
||||
expect(client.requests).toEqual([
|
||||
{ sessionID: "child", limit: 200, order: "desc" },
|
||||
{ sessionID: "child", limit: 200, cursor: "older" },
|
||||
])
|
||||
expect(store.data.message.child).toEqual([older, latest])
|
||||
expect(store.history.more("child")).toBe(false)
|
||||
})
|
||||
|
||||
// V2 messages are ordered projections and do not expose V1 assistant parent IDs.
|
||||
describe.skip("V1 assistant parent projections", () => {
|
||||
test("backfills an assistant-only initial page through its user root", async () => {
|
||||
@@ -798,7 +754,7 @@ describe("server session", () => {
|
||||
|
||||
await store.sync("child")
|
||||
|
||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 200, order: "desc" }])
|
||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, order: "desc" }])
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
|
||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||
expect(store.history.more("child")).toBe(false)
|
||||
|
||||
@@ -30,7 +30,8 @@ type MessageApi = ServerApi["message"]
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
const messagePageSize = 200
|
||||
const initialMessagePageSize = 20
|
||||
const historyMessagePageSize = 200
|
||||
const sessionInfoLimit = 2_048
|
||||
const emptyIDs: ReadonlySet<string> = new Set()
|
||||
|
||||
@@ -241,6 +242,7 @@ export function createServerSession(
|
||||
return created
|
||||
}
|
||||
const [meta, setMeta] = createStore({
|
||||
limit: {} as Record<string, number | undefined>,
|
||||
cursor: {} as Record<string, string | undefined>,
|
||||
complete: {} as Record<string, boolean | undefined>,
|
||||
loading: {} as Record<string, boolean | undefined>,
|
||||
@@ -421,6 +423,7 @@ export function createServerSession(
|
||||
setMeta(
|
||||
produce((draft) => {
|
||||
for (const sessionID of sessionIDs) {
|
||||
delete draft.limit[sessionID]
|
||||
delete draft.cursor[sessionID]
|
||||
delete draft.complete[sessionID]
|
||||
delete draft.loading[sessionID]
|
||||
@@ -454,13 +457,11 @@ export function createServerSession(
|
||||
pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }),
|
||||
)
|
||||
|
||||
const fetchMessages = async (sessionID: string, before?: string, onAttempt?: () => void) => {
|
||||
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
|
||||
const request = (cursor?: string) =>
|
||||
(options?.retry ?? retry)(() => {
|
||||
onAttempt?.()
|
||||
return messageApi.list(
|
||||
cursor ? { sessionID, limit: messagePageSize, cursor } : { sessionID, limit: messagePageSize, order: "desc" },
|
||||
)
|
||||
return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" })
|
||||
})
|
||||
const first = await request(before)
|
||||
const pages = [first]
|
||||
@@ -631,13 +632,14 @@ export function createServerSession(
|
||||
}
|
||||
orphanParts.delete(sessionID)
|
||||
}
|
||||
setMeta("limit", sessionID, messages.length)
|
||||
setMeta("cursor", sessionID, merged.cursor)
|
||||
setMeta("complete", sessionID, merged.complete)
|
||||
setMeta("at", sessionID, Date.now())
|
||||
})
|
||||
}
|
||||
|
||||
const loadMessages = async (sessionID: string, before?: string, mode?: "replace" | "prepend") => {
|
||||
const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => {
|
||||
if (meta.loading[sessionID]) return
|
||||
const active = generation(sessionID)
|
||||
const load: MessageLoadState = {
|
||||
@@ -656,7 +658,7 @@ export function createServerSession(
|
||||
setMeta("loading", sessionID, true)
|
||||
let applied = false
|
||||
try {
|
||||
const page = await fetchMessages(sessionID, before, () => resetMessageLoad(sessionID, load))
|
||||
const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
|
||||
const first = page.session.reduce<Message | undefined>(
|
||||
(oldest, message) => (!oldest || compareMessages(message, oldest) < 0 ? message : oldest),
|
||||
undefined,
|
||||
@@ -735,30 +737,32 @@ export function createServerSession(
|
||||
}
|
||||
}
|
||||
|
||||
const sync = (sessionID: string, options?: { force?: boolean }) => {
|
||||
const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => {
|
||||
touch(sessionID)
|
||||
return runInflight(inflight, sessionID, async () => {
|
||||
const cached = data.message[sessionID] !== undefined && meta.complete[sessionID] !== undefined
|
||||
const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined
|
||||
const invalid = invalidated.has(sessionID)
|
||||
const revision = invalidationRevision
|
||||
if (cached && data.info[sessionID] && !invalid && !options?.force) return
|
||||
await Promise.all([
|
||||
resolve(sessionID, invalid ? { ...options, force: true } : options),
|
||||
cached && !invalid && !options?.force ? Promise.resolve() : loadMessages(sessionID),
|
||||
cached && !invalid && !options?.force
|
||||
? Promise.resolve()
|
||||
: loadMessages(sessionID, options?.messageLimit ?? meta.limit[sessionID] ?? initialMessagePageSize),
|
||||
])
|
||||
if (invalid && invalidationRevision === revision) invalidated.delete(sessionID)
|
||||
})
|
||||
}
|
||||
|
||||
const prefetch = async (sessionID: string, messageCount: number) => {
|
||||
const prefetch = async (sessionID: string, limit: number) => {
|
||||
touch(sessionID)
|
||||
await inflight.get(sessionID)
|
||||
if (
|
||||
Date.now() - (meta.at[sessionID] ?? 0) <= 15_000 &&
|
||||
(meta.complete[sessionID] || (data.message[sessionID]?.length ?? 0) >= messageCount)
|
||||
(meta.complete[sessionID] || (data.message[sessionID]?.length ?? 0) >= limit)
|
||||
)
|
||||
return
|
||||
await runInflight(inflight, sessionID, () => loadMessages(sessionID))
|
||||
await runInflight(inflight, sessionID, () => loadMessages(sessionID, limit))
|
||||
}
|
||||
|
||||
const eventSessionID = (event: { type: string; properties?: unknown }) => {
|
||||
@@ -1350,11 +1354,11 @@ export function createServerSession(
|
||||
setMeta("at", {})
|
||||
},
|
||||
prefetch,
|
||||
shouldPrefetch(sessionID: string, messageCount: number) {
|
||||
shouldPrefetch(sessionID: string, limit: number) {
|
||||
if (data.message[sessionID] === undefined) return true
|
||||
if (Date.now() - (meta.at[sessionID] ?? 0) > 15_000) return true
|
||||
if (meta.complete[sessionID]) return false
|
||||
return (data.message[sessionID]?.length ?? 0) <= messageCount
|
||||
return (meta.limit[sessionID] ?? 0) <= limit
|
||||
},
|
||||
fresh(sessionID: string, ttl: number) {
|
||||
return Date.now() - (meta.at[sessionID] ?? 0) <= ttl
|
||||
@@ -1436,14 +1440,14 @@ export function createServerSession(
|
||||
history: {
|
||||
more: (sessionID: string) =>
|
||||
data.message[sessionID] !== undefined &&
|
||||
meta.complete[sessionID] !== undefined &&
|
||||
meta.limit[sessionID] !== undefined &&
|
||||
!meta.complete[sessionID] &&
|
||||
!!meta.cursor[sessionID],
|
||||
loading: (sessionID: string) => meta.loading[sessionID] ?? false,
|
||||
async loadMore(sessionID: string) {
|
||||
async loadMore(sessionID: string, count = historyMessagePageSize) {
|
||||
touch(sessionID)
|
||||
if (meta.loading[sessionID] || meta.complete[sessionID] || !meta.cursor[sessionID]) return
|
||||
await loadMessages(sessionID, meta.cursor[sessionID], "prepend")
|
||||
await loadMessages(sessionID, count, meta.cursor[sessionID], "prepend")
|
||||
},
|
||||
},
|
||||
evict(sessionID: string) {
|
||||
|
||||
@@ -192,7 +192,7 @@ export type ProviderInfo = {
|
||||
id: string
|
||||
integrationID?: string
|
||||
name: string
|
||||
activation: "auto" | "enabled" | "disabled"
|
||||
disabled?: boolean
|
||||
package: string
|
||||
settings?: { [x: string]: any }
|
||||
headers?: { [x: string]: string }
|
||||
|
||||
@@ -65,8 +65,8 @@ const layer = Layer.effect(
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
const available = (provider: Provider.Info, integration: Integration.Info | undefined) => {
|
||||
if (provider.activation === "disabled") return false
|
||||
if (provider.activation === "enabled") return true
|
||||
if (provider.disabled) return false
|
||||
if (typeof provider.settings?.apiKey === "string") return true
|
||||
if (integration?.connections.length) return true
|
||||
return provider.integrationID === undefined && !integration
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ConfigAgentV1 } from "../../v1/config/agent.js"
|
||||
import { ConfigMigrateV1 } from "../../v1/config/migrate.js"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Permission } from "../../permission.js"
|
||||
import type { LocationMutation } from "../../location-mutation.js"
|
||||
import type { LocationPath } from "../../location-path.js"
|
||||
import type { ReadTool } from "../../tool/plugin/read.js"
|
||||
import type { EditTool } from "../../tool/plugin/edit.js"
|
||||
import { AbsolutePath } from "../../schema.js"
|
||||
@@ -28,7 +28,7 @@ const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
|
||||
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
|
||||
const decodeConfig = Schema.decodeUnknownOption(Info)
|
||||
type PathAction =
|
||||
| LocationMutation.ExternalDirectoryAuthorization["action"]
|
||||
| LocationPath.ExternalDirectoryAuthorization["action"]
|
||||
| typeof ReadTool.name
|
||||
| typeof EditTool.name
|
||||
const pathActions = ["external_directory", "read", "edit"] as const satisfies readonly PathAction[]
|
||||
|
||||
@@ -40,7 +40,6 @@ export const Plugin = define({
|
||||
for (const [id, item] of configuredProviders(loaded.entries)) {
|
||||
const providerID = id
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.activation = "enabled"
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.package !== undefined) provider.package = item.package
|
||||
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as LocationMutation from "./location-mutation.js"
|
||||
export * as LocationPath from "./location-path.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import path from "path"
|
||||
@@ -12,8 +12,8 @@ export const Kind = Schema.Literals(["file", "directory"])
|
||||
export type Kind = typeof Kind.Type
|
||||
|
||||
/**
|
||||
* Mutation paths do not accept project references. Relative paths resolve
|
||||
* from the active Location. Paths outside it require separate
|
||||
* Tool paths do not accept project references. Relative paths resolve from
|
||||
* the active Location. Paths outside its project require separate
|
||||
* `external_directory` approval.
|
||||
*/
|
||||
export const ResolveInput = Schema.Struct({
|
||||
@@ -49,13 +49,13 @@ export interface Target {
|
||||
export interface Interface {
|
||||
/**
|
||||
* Resolve a path and derive its permission resources. Relative paths resolve
|
||||
* from the Location. Paths outside it require separate `external_directory`
|
||||
* approval. This does not approve the mutation.
|
||||
* from the Location. Paths outside its project require separate
|
||||
* `external_directory` approval. This does not approve access.
|
||||
*/
|
||||
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationPath") {}
|
||||
|
||||
const slash = (value: string) => value.replaceAll("\\", "/")
|
||||
|
||||
@@ -65,9 +65,13 @@ const layer = Layer.effect(
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
|
||||
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
||||
const resolve = Effect.fn("LocationPath.resolve")(function* (input: ResolveInput) {
|
||||
const absolute = path.resolve(location.directory, input.path)
|
||||
if (FSUtil.contains(location.directory, absolute)) {
|
||||
const projectRoot = path.parse(location.project.directory).root
|
||||
if (
|
||||
FSUtil.contains(location.directory, absolute) ||
|
||||
(location.project.directory !== projectRoot && FSUtil.contains(location.project.directory, absolute))
|
||||
) {
|
||||
return {
|
||||
absolute,
|
||||
resource: slash(path.relative(location.directory, absolute) || "."),
|
||||
@@ -19,7 +19,7 @@ import { Image } from "./image.js"
|
||||
import { LocationWatcher } from "./filesystem/location-watcher.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationMutation } from "./location-mutation.js"
|
||||
import { LocationPath } from "./location-path.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { ModelResolver } from "./model-resolver.js"
|
||||
import { MCP } from "./mcp/index.js"
|
||||
@@ -76,7 +76,7 @@ const locationServiceNodes = [
|
||||
Skill.node,
|
||||
InstructionBuiltIns.node,
|
||||
InstructionDiscovery.node,
|
||||
LocationMutation.node,
|
||||
LocationPath.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
MCP.node,
|
||||
|
||||
@@ -358,21 +358,17 @@ export const layer = Layer.effect(
|
||||
const connection = yield* integrations.connection.active(
|
||||
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
||||
)
|
||||
const credential = connection ? yield* integrations.connection.resolve(connection) : undefined
|
||||
const runtimeInfo = yield* withVariant(selected, variant)
|
||||
const model = yield* fromCatalogModel(runtimeInfo, credential, {
|
||||
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
|
||||
loadAISDK: (model) => aisdk.model(model),
|
||||
})
|
||||
const runtime =
|
||||
provider?.activation === "enabled" &&
|
||||
credential === undefined &&
|
||||
!hasConfiguredAuth(runtimeInfo) &&
|
||||
usesAPIKeyAuth(runtimeInfo.package)
|
||||
? LanguageModel.update(model, { route: model.route.with({ auth: Auth.none }) })
|
||||
: model
|
||||
const model = yield* resolveModel(
|
||||
selected,
|
||||
variant,
|
||||
connection ? yield* integrations.connection.resolve(connection) : undefined,
|
||||
{
|
||||
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
|
||||
loadAISDK: (model) => aisdk.model(model),
|
||||
},
|
||||
)
|
||||
return {
|
||||
model: runtime,
|
||||
model,
|
||||
ref: Ref.make({
|
||||
id: selected.id,
|
||||
providerID: selected.providerID,
|
||||
@@ -403,35 +399,6 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
function hasConfiguredAuth(model: Info) {
|
||||
return [model.settings?.apiKey, model.settings?.authToken, model.settings?.accessToken].some(
|
||||
(value) => typeof value === "string" && value !== "",
|
||||
)
|
||||
}
|
||||
|
||||
function usesAPIKeyAuth(packageName: string | undefined) {
|
||||
const name = Provider.packageName(packageName)
|
||||
return (
|
||||
name === "@ai-sdk/openai" ||
|
||||
name === "@ai-sdk/anthropic" ||
|
||||
name === "@ai-sdk/openai-compatible" ||
|
||||
name === "@ai-sdk/google" ||
|
||||
name === "@ai-sdk/xai" ||
|
||||
name === "@openrouter/ai-sdk-provider" ||
|
||||
name === "@ai-sdk/azure" ||
|
||||
name === "@opencode-ai/ai/providers/openai" ||
|
||||
name?.startsWith("@opencode-ai/ai/providers/openai/") === true ||
|
||||
name === "@opencode-ai/ai/providers/anthropic" ||
|
||||
name === "@opencode-ai/ai/providers/anthropic-compatible" ||
|
||||
name === "@opencode-ai/ai/providers/openai-compatible" ||
|
||||
name === "@opencode-ai/ai/providers/google" ||
|
||||
name === "@opencode-ai/ai/providers/xai" ||
|
||||
name === "@opencode-ai/ai/providers/openrouter" ||
|
||||
name === "@opencode-ai/ai/providers/azure" ||
|
||||
name?.startsWith("@opencode-ai/ai/providers/azure/") === true
|
||||
)
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
export * as ModelRouting from "./model-routing.js"
|
||||
|
||||
import { Model } from "./model.js"
|
||||
import { Provider } from "./provider.js"
|
||||
|
||||
export const roles = ["fast", "smart", "vision", "long-context"] as const
|
||||
export type Role = (typeof roles)[number]
|
||||
|
||||
export function resolve(selection: string, available: readonly Model.Info[]) {
|
||||
if (!isRole(selection)) return exact(selection, available)
|
||||
return select(selection, available)
|
||||
}
|
||||
|
||||
export function select(role: Role, available: readonly Model.Info[]) {
|
||||
const candidates = available.filter(
|
||||
(model) =>
|
||||
model.status === "active" &&
|
||||
model.capabilities.tools &&
|
||||
model.capabilities.input.includes("text") &&
|
||||
model.capabilities.output.includes("text"),
|
||||
)
|
||||
const eligible =
|
||||
role === "vision"
|
||||
? candidates.filter((model) => model.capabilities.input.includes("image"))
|
||||
: role === "fast"
|
||||
? candidates.filter((model) => !SLOW_MODEL_RE.test(identity(model)))
|
||||
: candidates
|
||||
if (eligible.length === 0) return
|
||||
|
||||
const sorted = eligible.toSorted((a, b) => {
|
||||
if (role === "fast") {
|
||||
const tagged = Number(fast(b)) - Number(fast(a))
|
||||
if (tagged !== 0) return tagged
|
||||
const price = cost(a) - cost(b)
|
||||
if (price !== 0) return price
|
||||
}
|
||||
if (role === "smart" || role === "vision") {
|
||||
const tagged = Number(smart(b)) - Number(smart(a))
|
||||
if (tagged !== 0) return tagged
|
||||
}
|
||||
if (role === "long-context") {
|
||||
const context = b.limit.context - a.limit.context
|
||||
if (context !== 0) return context
|
||||
}
|
||||
const released = b.time.released - a.time.released
|
||||
if (released !== 0) return released
|
||||
return `${a.providerID}/${a.id}`.localeCompare(`${b.providerID}/${b.id}`)
|
||||
})
|
||||
const selected = sorted[0]
|
||||
return Model.Ref.make({ providerID: selected.providerID, id: selected.id })
|
||||
}
|
||||
|
||||
function isRole(selection: string): selection is Role {
|
||||
return roles.includes(selection as Role)
|
||||
}
|
||||
|
||||
function exact(selection: string, available: readonly Model.Info[]) {
|
||||
const providerEnd = selection.indexOf("/")
|
||||
if (providerEnd <= 0) return
|
||||
const variantStart = selection.indexOf("#", providerEnd + 1)
|
||||
const providerID = Provider.ID.make(selection.slice(0, providerEnd))
|
||||
const id = Model.ID.make(selection.slice(providerEnd + 1, variantStart === -1 ? undefined : variantStart))
|
||||
const variant = variantStart === -1 ? undefined : Model.VariantID.make(selection.slice(variantStart + 1))
|
||||
if (!id || !providerID || (variantStart !== -1 && !variant)) return
|
||||
const model = available.find((item) => item.providerID === providerID && item.id === id)
|
||||
if (!model) return
|
||||
if (variant && !model.variants.some((item) => item.id === variant)) return
|
||||
return Model.Ref.make({ providerID, id, variant })
|
||||
}
|
||||
|
||||
function cost(model: Model.Info) {
|
||||
const price = model.cost[0]
|
||||
return price ? price.input + price.output : Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
|
||||
function fast(model: Model.Info) {
|
||||
return FAST_MODEL_RE.test(identity(model))
|
||||
}
|
||||
|
||||
function smart(model: Model.Info) {
|
||||
return SMART_MODEL_RE.test(identity(model))
|
||||
}
|
||||
|
||||
function identity(model: Model.Info) {
|
||||
return `${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()
|
||||
}
|
||||
|
||||
const FAST_MODEL_RE = /\b(nano|flash|lite|mini|small|fast)\b/
|
||||
const SLOW_MODEL_RE = /\b(haiku)\b/
|
||||
const SMART_MODEL_RE = /\b(opus|pro|max|ultra|reasoner|reasoning)\b|\b(gpt-5|grok-4|deepseek-v4|kimi-k2)\b/
|
||||
@@ -86,7 +86,6 @@ function normalize(input: Record<string, SourceProvider>): readonly Snapshot[] {
|
||||
const info = {
|
||||
id: providerID,
|
||||
name: item.name,
|
||||
activation: "auto",
|
||||
package: Provider.aisdk(item.npm),
|
||||
...(item.api ? { settings: { baseURL: item.api } } : {}),
|
||||
} satisfies Provider.Info
|
||||
|
||||
@@ -32,7 +32,7 @@ import { InstructionDiscovery } from "../instruction-discovery.js"
|
||||
import { Integration } from "../integration.js"
|
||||
import { KV } from "../kv.js"
|
||||
import { Location } from "../location.js"
|
||||
import { LocationMutation } from "../location-mutation.js"
|
||||
import { LocationPath } from "../location-path.js"
|
||||
import { ModelsDev } from "../models-dev.js"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Permission } from "../permission.js"
|
||||
@@ -92,7 +92,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const integration = yield* Integration.Service
|
||||
const kv = yield* KV.Service
|
||||
const location = yield* Location.Service
|
||||
const locationMutation = yield* LocationMutation.Service
|
||||
const locationMutation = yield* LocationPath.Service
|
||||
const models = yield* ModelsDev.Service
|
||||
const npm = yield* Npm.Service
|
||||
const permission = yield* Permission.Service
|
||||
@@ -129,7 +129,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(Integration.Service, integration),
|
||||
Context.make(KV.Service, kv),
|
||||
Context.make(Location.Service, location),
|
||||
Context.make(LocationMutation.Service, locationMutation),
|
||||
Context.make(LocationPath.Service, locationMutation),
|
||||
Context.make(ModelsDev.Service, models),
|
||||
Context.make(Npm.Service, npm),
|
||||
Context.make(Permission.Service, permission),
|
||||
@@ -173,7 +173,7 @@ export const requirements = LayerNode.group([
|
||||
Integration.node,
|
||||
KV.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
LocationPath.node,
|
||||
ModelsDev.node,
|
||||
Npm.node,
|
||||
Permission.node,
|
||||
|
||||
@@ -10,7 +10,7 @@ export const LLMGatewayPlugin = define({
|
||||
const configured = new Set((yield* integrations.list()).map((integration) => integration.id))
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.activation === "disabled") continue
|
||||
if (item.provider.disabled) continue
|
||||
if (!Provider.isAISDK(item.provider.package)) continue
|
||||
if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.settings?.baseURL !== "https://api.llmgateway.io/v1") continue
|
||||
|
||||
@@ -178,10 +178,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
if (!item) return
|
||||
const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.settings?.apiKey)
|
||||
catalog.provider.update(item.provider.id, (provider) => {
|
||||
if (!hasKey) {
|
||||
provider.activation = "enabled"
|
||||
provider.settings = { ...provider.settings, apiKey: "public" }
|
||||
}
|
||||
if (!hasKey) provider.settings = { ...provider.settings, apiKey: "public" }
|
||||
})
|
||||
if (hasKey) return
|
||||
for (const model of item.models.values()) {
|
||||
|
||||
@@ -68,7 +68,7 @@ every field, examples, config locations, and links to dedicated feature guides.
|
||||
For any request to migrate OpenCode configuration, agents, commands, skills,
|
||||
plugins, integrations, or other behavior from V1 to V2, read the full
|
||||
[migration guide](https://opencode.ai/v2/docs/migrate-v1) before acting. In
|
||||
the repository, its source is `packages/www/content/docs/migrate-v1.mdx`.
|
||||
the repository, its source is `packages/www/content/docs/(Get started)/migrate-v1.mdx`.
|
||||
|
||||
V1 config files and `.opencode/` definitions are intended to remain compatible.
|
||||
The only intentional breaking changes are the server API and plugin API. Native
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Environment } from "../../environment/index.js"
|
||||
import { FileMutation } from "../../file-mutation.js"
|
||||
import { Formatter } from "../../formatter.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { LocationPath } from "../../location-path.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { fileDiff } from "./file-diff.js"
|
||||
|
||||
@@ -110,7 +110,7 @@ const findLineOccurrences = (content: string, search: string) => {
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.edit",
|
||||
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const mutation = yield* LocationPath.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
const environment = yield* Environment.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
@@ -148,7 +148,7 @@ export const Plugin = {
|
||||
const external = target.externalDirectory
|
||||
if (external) {
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
...LocationPath.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: permissionSource,
|
||||
|
||||
@@ -7,7 +7,7 @@ import path from "path"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
import { FileSystem } from "../../filesystem.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { LocationPath } from "../../location-path.js"
|
||||
import { Ripgrep } from "../../ripgrep.js"
|
||||
import { RelativePath } from "../../schema.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
@@ -45,7 +45,7 @@ export const Plugin = {
|
||||
const environment = yield* Environment.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const location = yield* Location.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const mutation = yield* LocationPath.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
@@ -64,7 +64,7 @@ export const Plugin = {
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
...LocationPath.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
|
||||
@@ -7,7 +7,7 @@ import path from "path"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
import { FileSystem } from "../../filesystem.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { LocationPath } from "../../location-path.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { Ripgrep } from "../../ripgrep.js"
|
||||
import { RelativePath } from "../../schema.js"
|
||||
@@ -61,7 +61,7 @@ export const Plugin = {
|
||||
const environment = yield* Environment.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const location = yield* Location.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const mutation = yield* LocationPath.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
@@ -79,7 +79,7 @@ export const Plugin = {
|
||||
const target = yield* mutation.resolve({ path: input.path ?? "." })
|
||||
if (target.externalDirectory)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
|
||||
...LocationPath.externalDirectoryPermission(target.externalDirectory),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Environment } from "../../environment/index.js"
|
||||
import { Formatter } from "../../formatter.js"
|
||||
import { FileMutation } from "../../file-mutation.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { LocationPath } from "../../location-path.js"
|
||||
import { Patch } from "@opencode-ai/util/patch"
|
||||
import { Permission } from "../../permission.js"
|
||||
import DESCRIPTION from "../patch.txt"
|
||||
@@ -46,29 +46,29 @@ export const toModelOutput = (output: Output) =>
|
||||
|
||||
type Prepared =
|
||||
| (Extract<Patch.Hunk, { readonly type: "add" }> & {
|
||||
readonly target: LocationMutation.Target
|
||||
readonly target: LocationPath.Target
|
||||
readonly content: string
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
})
|
||||
| (Extract<Patch.Hunk, { readonly type: "delete" }> & {
|
||||
readonly target: LocationMutation.Target
|
||||
readonly target: LocationPath.Target
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
})
|
||||
| (Extract<Patch.Hunk, { readonly type: "update" }> & {
|
||||
readonly target: LocationMutation.Target
|
||||
readonly target: LocationPath.Target
|
||||
readonly content: string
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
readonly moveTarget?: LocationMutation.Target
|
||||
readonly moveTarget?: LocationPath.Target
|
||||
})
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.patch",
|
||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const environment = yield* Environment.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const mutation = yield* LocationPath.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const location = yield* Location.Service
|
||||
@@ -116,7 +116,7 @@ export const Plugin = {
|
||||
const target = yield* mutation.resolve({ path: value, kind: "file" })
|
||||
if (!target.externalDirectory) return target
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
|
||||
...LocationPath.externalDirectoryPermission(target.externalDirectory),
|
||||
metadata: {
|
||||
filepath: target.absolute,
|
||||
parentDir: target.externalDirectory.directory,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "../../location.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { LocationPath } from "../../location-path.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { SessionInstructions } from "../../session/instructions.js"
|
||||
import { AbsolutePath } from "../../schema.js"
|
||||
@@ -32,7 +32,7 @@ export const Plugin = {
|
||||
id: "opencode.tool.read",
|
||||
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const reader = yield* ReadToolFileSystem.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const mutation = yield* LocationPath.Service
|
||||
const permission = yield* Permission.Service
|
||||
const sessionInstructions = yield* SessionInstructions.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
@@ -58,7 +58,7 @@ export const Plugin = {
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
...LocationPath.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
|
||||
import { Deferred, Effect, Schema, Scope } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { LocationPath } from "../../location-path.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { PluginRuntime } from "../../plugin/runtime.js"
|
||||
import { NonNegativeInt } from "../../schema.js"
|
||||
@@ -84,7 +84,7 @@ export const Plugin = {
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const environment = yield* Environment.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const mutation = yield* LocationPath.Service
|
||||
const shell = yield* Shell.Service
|
||||
const permission = yield* Permission.Service
|
||||
const config = yield* Config.Service
|
||||
|
||||
@@ -4,9 +4,7 @@ import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Catalog } from "../../catalog.js"
|
||||
import { Config } from "../../config.js"
|
||||
import { ModelRouting } from "../../model-routing.js"
|
||||
import { PluginRuntime } from "../../plugin/runtime.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { SessionSchema } from "../../session/schema.js"
|
||||
@@ -25,10 +23,6 @@ export const Input = Schema.Struct({
|
||||
agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
|
||||
description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }),
|
||||
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
|
||||
model: Schema.optionalKey(Schema.String).annotate({
|
||||
description:
|
||||
'Optional model route. Use "fast" for cheap bounded work, "smart" for difficult reasoning, "vision" for image input, or "long-context" for very large inputs. Pass an exact provider/model ID only when the user requests one. Omit this field to use the agent model or inherit the parent model.',
|
||||
}),
|
||||
background: Schema.optionalKey(Schema.Boolean).annotate({
|
||||
description:
|
||||
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress.",
|
||||
@@ -53,7 +47,6 @@ export const Plugin = {
|
||||
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const agents = yield* Agent.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const config = yield* Config.Service
|
||||
const permission = yield* Permission.Service
|
||||
const scope = yield* Scope.Scope
|
||||
@@ -170,14 +163,8 @@ export const Plugin = {
|
||||
})
|
||||
.pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error })))
|
||||
|
||||
const routed = input.model
|
||||
? ModelRouting.resolve(input.model, yield* catalog.model.available())
|
||||
: undefined
|
||||
if (input.model && !routed)
|
||||
return yield* new ToolFailure({
|
||||
message: `No available model matches route: ${input.model}`,
|
||||
})
|
||||
const model = routed ?? agent.model ?? parent.model
|
||||
// Model selection is policy/config/session state, not an LLM-facing tool argument.
|
||||
const model = agent.model ?? parent.model
|
||||
const child = yield* runtime.session
|
||||
.create({
|
||||
parentID: context.sessionID,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Bom } from "@opencode-ai/util/bom"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
import { FileMutation } from "../../file-mutation.js"
|
||||
import { Formatter } from "../../formatter.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { LocationPath } from "../../location-path.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { fileDiff } from "./file-diff.js"
|
||||
|
||||
@@ -46,7 +46,7 @@ export const toModelOutput = (output: Output) =>
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.write",
|
||||
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const mutation = yield* LocationPath.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
const environment = yield* Environment.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
@@ -72,7 +72,7 @@ export const Plugin = {
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
...LocationPath.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
|
||||
@@ -102,35 +102,6 @@ describe("Catalog", () => {
|
||||
}).pipe(Effect.provide(localCatalogLayer))
|
||||
})
|
||||
|
||||
it.effect("makes an explicitly enabled provider available without a connection", () => {
|
||||
const integrationID = Integration.ID.make("gateway")
|
||||
const providerID = Provider.ID.make("remote")
|
||||
const localCatalogLayer = Layer.fresh(
|
||||
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [
|
||||
[Location.node, locationLayer],
|
||||
]),
|
||||
)
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {}))
|
||||
yield* catalog.transform((editor) =>
|
||||
editor.provider.update(providerID, (provider) => {
|
||||
provider.integrationID = integrationID
|
||||
provider.settings = { baseURL: "https://gateway.example.com/v1" }
|
||||
}),
|
||||
)
|
||||
expect(yield* catalog.provider.available()).toEqual([])
|
||||
|
||||
yield* catalog.transform((editor) =>
|
||||
editor.provider.update(providerID, (provider) => {
|
||||
provider.activation = "enabled"
|
||||
}),
|
||||
)
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([providerID])
|
||||
}).pipe(Effect.provide(localCatalogLayer))
|
||||
})
|
||||
|
||||
it.effect("projects environment connections without a catalog plugin", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
@@ -307,7 +278,7 @@ describe("Catalog", () => {
|
||||
const fallbackModel = Model.ID.make("fallback")
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(disabledProvider, (provider) => {
|
||||
provider.activation = "disabled"
|
||||
provider.disabled = true
|
||||
})
|
||||
catalog.model.update(disabledProvider, disabledModel, () => {})
|
||||
catalog.provider.update(enabledProvider, () => {})
|
||||
|
||||
@@ -342,7 +342,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
names: ["CUSTOM_API_KEY"],
|
||||
})
|
||||
expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed")
|
||||
expect(provider.activation).toBe("enabled")
|
||||
expect(provider.disabled).toBeUndefined()
|
||||
expect(provider.package).toBe("aisdk:custom-sdk")
|
||||
expect(provider.settings).toEqual({ baseURL: "https://example.test" })
|
||||
expect(provider.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
|
||||
@@ -7,7 +7,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { type EnvironmentFilesTransform, transformEnvironmentFiles } from "./fixture/environment"
|
||||
import { location } from "./fixture/location"
|
||||
@@ -20,7 +20,7 @@ function provide(directory: string, transformFiles: EnvironmentFilesTransform =
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
)
|
||||
return Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
|
||||
AppNodeBuilder.build(LayerNode.group([LocationPath.node, FileMutation.node]), [
|
||||
[Location.node, activeLocation],
|
||||
[Environment.node, transformEnvironmentFiles(activeLocation, transformFiles)],
|
||||
]),
|
||||
@@ -40,7 +40,7 @@ describe("FileMutation", () => {
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "hello.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "before"))
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
|
||||
const target = yield* (yield* LocationPath.Service).resolve({ path: "hello.txt" })
|
||||
|
||||
expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({
|
||||
operation: "write",
|
||||
@@ -56,7 +56,7 @@ describe("FileMutation", () => {
|
||||
it.live("writes a prospective internal file and creates parent directories", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({
|
||||
const target = yield* (yield* LocationPath.Service).resolve({
|
||||
path: path.join("src", "nested", "hello.txt"),
|
||||
})
|
||||
const result = yield* (yield* FileMutation.Service).write({ target, content: "hello" })
|
||||
@@ -77,8 +77,8 @@ describe("FileMutation", () => {
|
||||
Effect.gen(function* () {
|
||||
const preservedPath = path.join(directory, "preserved.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(preservedPath, "\uFEFFbefore"))
|
||||
const preserved = yield* (yield* LocationMutation.Service).resolve({ path: "preserved.txt" })
|
||||
const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
|
||||
const preserved = yield* (yield* LocationPath.Service).resolve({ path: "preserved.txt" })
|
||||
const created = yield* (yield* LocationPath.Service).resolve({ path: "created.txt" })
|
||||
const files = yield* FileMutation.Service
|
||||
|
||||
yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
|
||||
@@ -95,7 +95,7 @@ describe("FileMutation", () => {
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "external.txt")
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const target = yield* (yield* LocationPath.Service).resolve({ path: targetPath })
|
||||
const result = yield* (yield* FileMutation.Service).write({ target, content: "external" })
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -133,7 +133,7 @@ describe("FileMutation", () => {
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const mutation = yield* LocationPath.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const firstPlan = yield* mutation.resolve({ path: "shared.txt" })
|
||||
const secondPlan = yield* mutation.resolve({ path: "shared.txt" })
|
||||
@@ -222,7 +222,7 @@ describe("FileMutation", () => {
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const mutation = yield* LocationPath.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const firstPlan = yield* mutation.resolve({ path: "first.txt" })
|
||||
const secondPlan = yield* mutation.resolve({ path: "second.txt" })
|
||||
|
||||
+59
-18
@@ -4,18 +4,26 @@ import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { location } from "./fixture/location"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
function provide(directory: string) {
|
||||
function provide(directory: string, projectDirectory = directory) {
|
||||
return Effect.provide(
|
||||
LayerNode.compile(LocationMutation.node, [
|
||||
LayerNode.compile(LocationPath.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(projectDirectory) },
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
)
|
||||
@@ -28,13 +36,13 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
||||
}
|
||||
|
||||
describe("LocationMutation", () => {
|
||||
describe("LocationPath", () => {
|
||||
it.live("resolves an active relative existing file target", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "hello.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "hello"))
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
|
||||
const target = yield* (yield* LocationPath.Service).resolve({ path: "hello.txt" })
|
||||
|
||||
expect(target).toMatchObject({
|
||||
absolute: targetPath,
|
||||
@@ -49,7 +57,7 @@ describe("LocationMutation", () => {
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
|
||||
const target = yield* (yield* LocationPath.Service).resolve({ path: path.join("src", "new.txt") })
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(directory, "src", "new.txt"),
|
||||
resource: "src/new.txt",
|
||||
@@ -58,10 +66,43 @@ describe("LocationMutation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not require external authorization inside the project but outside the active directory", () =>
|
||||
withTmp((project) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(project, "packages", "app")
|
||||
const targetPath = path.join(project, "README.md")
|
||||
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "hello"))
|
||||
|
||||
const locationPath = yield* LocationPath.Service
|
||||
const target = yield* locationPath.resolve({ path: targetPath })
|
||||
|
||||
expect(target).toMatchObject({
|
||||
absolute: targetPath,
|
||||
resource: "../../README.md",
|
||||
})
|
||||
expect(target.externalDirectory).toBeUndefined()
|
||||
}).pipe(provide(path.join(project, "packages", "app"), project)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not treat a filesystem-root project fallback as internal", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const locationPath = yield* LocationPath.Service
|
||||
const target = yield* locationPath.resolve({ path: path.join(outside, "target.txt") })
|
||||
|
||||
expect(target.externalDirectory).toBeDefined()
|
||||
}).pipe(provide(directory, path.parse(directory).root)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("requires external-directory authorization for a relative lexical escape", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
|
||||
const target = yield* (yield* LocationPath.Service).resolve({ path: "../outside.txt" })
|
||||
const root = path.dirname(directory)
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(root, "outside.txt"),
|
||||
@@ -84,7 +125,7 @@ describe("LocationMutation", () => {
|
||||
await fs.mkdir(outside)
|
||||
await fs.symlink(outside, path.join(directory, "escape"))
|
||||
})
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
|
||||
const target = yield* (yield* LocationPath.Service).resolve({ path: path.join("escape", "new.txt") })
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(directory, "escape", "new.txt"),
|
||||
resource: "escape/new.txt",
|
||||
@@ -104,7 +145,7 @@ describe("LocationMutation", () => {
|
||||
await fs.symlink(path.join(directory, "actual"), path.join(directory, "linked"))
|
||||
})
|
||||
|
||||
expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
|
||||
expect(yield* (yield* LocationPath.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
|
||||
absolute: path.join(directory, "linked", "new.txt"),
|
||||
resource: "linked/new.txt",
|
||||
})
|
||||
@@ -116,7 +157,7 @@ describe("LocationMutation", () => {
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "new.txt")
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const target = yield* (yield* LocationPath.Service).resolve({ path: targetPath })
|
||||
expect(target).toMatchObject({
|
||||
absolute: targetPath,
|
||||
resource: "new.txt",
|
||||
@@ -131,7 +172,7 @@ describe("LocationMutation", () => {
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "new.txt")
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const target = yield* (yield* LocationPath.Service).resolve({ path: targetPath })
|
||||
const root = outside
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(root, "new.txt"),
|
||||
@@ -152,7 +193,7 @@ describe("LocationMutation", () => {
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "existing.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const target = yield* (yield* LocationPath.Service).resolve({ path: targetPath })
|
||||
expect(target).toMatchObject({ absolute: targetPath })
|
||||
expect(target.externalDirectory?.directory).toBe(outside)
|
||||
}).pipe(provide(directory)),
|
||||
@@ -164,7 +205,7 @@ describe("LocationMutation", () => {
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: outside, kind: "file" })
|
||||
const target = yield* (yield* LocationPath.Service).resolve({ path: outside, kind: "file" })
|
||||
expect(target.externalDirectory).toMatchObject({
|
||||
directory: path.dirname(outside),
|
||||
resource: path.join(path.dirname(outside), "*").replaceAll("\\", "/"),
|
||||
@@ -179,7 +220,7 @@ describe("LocationMutation", () => {
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "new", "nested", "file.txt")
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const target = yield* (yield* LocationPath.Service).resolve({ path: targetPath })
|
||||
const parent = path.dirname(targetPath)
|
||||
expect(target.externalDirectory).toMatchObject({
|
||||
directory: parent,
|
||||
@@ -190,9 +231,9 @@ describe("LocationMutation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
test("ignores unknown mutation input fields", () => {
|
||||
expect(Object.keys(LocationMutation.ResolveInput.fields)).toEqual(["path", "kind"])
|
||||
expect(Schema.decodeUnknownSync(LocationMutation.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
|
||||
test("ignores unknown path input fields", () => {
|
||||
expect(Object.keys(LocationPath.ResolveInput.fields)).toEqual(["path", "kind"])
|
||||
expect(Schema.decodeUnknownSync(LocationPath.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
|
||||
path: "README.md",
|
||||
})
|
||||
})
|
||||
@@ -2,16 +2,13 @@ import { describe, expect } from "bun:test"
|
||||
import { LLM, LanguageModel } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { compileRequest } from "@opencode-ai/ai/route/client"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Compatibility, ID, Info, VariantID } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { ModelResolver } from "@opencode-ai/core/model-resolver"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
interface ModelOptions {
|
||||
@@ -272,109 +269,6 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses no native API-key auth for an explicitly enabled provider without credentials", () => {
|
||||
const selected = model(Provider.aisdk("@ai-sdk/google"), {
|
||||
providerID: Provider.ID.make("gateway"),
|
||||
settings: { baseURL: "https://gateway.example.com/v1" },
|
||||
headers: { "cf-access-token": "access-token" },
|
||||
})
|
||||
const provider = Provider.Info.make({
|
||||
...Provider.Info.empty(selected.providerID),
|
||||
activation: "enabled",
|
||||
package: selected.package ?? "",
|
||||
settings: selected.settings,
|
||||
headers: selected.headers,
|
||||
})
|
||||
const catalog = Layer.mock(Catalog.Service, {
|
||||
provider: {
|
||||
get: () => Effect.succeed(provider),
|
||||
all: () => Effect.die("unused"),
|
||||
available: () => Effect.die("unused"),
|
||||
},
|
||||
model: {
|
||||
get: () => Effect.succeed(selected),
|
||||
all: () => Effect.die("unused"),
|
||||
available: () => Effect.die("unused"),
|
||||
default: () => Effect.die("unused"),
|
||||
small: () => Effect.die("unused"),
|
||||
},
|
||||
})
|
||||
const integrations = Layer.mock(Integration.Service, {
|
||||
connection: {
|
||||
active: () => Effect.succeed(undefined),
|
||||
resolve: () => Effect.die("unused"),
|
||||
key: () => Effect.die("unused"),
|
||||
update: () => Effect.die("unused"),
|
||||
remove: () => Effect.die("unused"),
|
||||
},
|
||||
oauth: {
|
||||
connect: () => Effect.die("unused"),
|
||||
status: () => Effect.die("unused"),
|
||||
complete: () => Effect.die("unused"),
|
||||
cancel: () => Effect.die("unused"),
|
||||
},
|
||||
command: {
|
||||
connect: () => Effect.die("unused"),
|
||||
status: () => Effect.die("unused"),
|
||||
cancel: () => Effect.die("unused"),
|
||||
},
|
||||
})
|
||||
const npm = Layer.mock(Npm.Service, {
|
||||
add: () => Effect.die("unused"),
|
||||
which: () => Effect.die("unused"),
|
||||
})
|
||||
const aisdk = Layer.mock(AISDK.Service, {
|
||||
hook: {
|
||||
sdk: () => Effect.die("unused"),
|
||||
language: () => Effect.die("unused"),
|
||||
},
|
||||
model: () => Effect.die("unused"),
|
||||
})
|
||||
const layer = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
|
||||
|
||||
return withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const resolver = yield* ModelResolver.Service
|
||||
const resolved = yield* resolver.resolveModel(selected)
|
||||
|
||||
const headers = yield* resolved.model.route.auth.apply({
|
||||
request: LLM.request({ model: resolved.model, prompt: "Hello" }),
|
||||
method: "POST",
|
||||
url: "https://gateway.example.com/v1",
|
||||
body: "{}",
|
||||
headers: Headers.fromInput(resolved.model.route.defaults.headers),
|
||||
})
|
||||
|
||||
expect(headers["cf-access-token"]).toBe("access-token")
|
||||
expect(headers.authorization).toBeUndefined()
|
||||
expect(headers["x-goog-api-key"]).toBeUndefined()
|
||||
}).pipe(Effect.provide(layer)),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("keeps native provider environment auth strict when no API key is configured", () =>
|
||||
withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/google"), {
|
||||
settings: { baseURL: "https://google.example.com/v1" },
|
||||
}),
|
||||
)
|
||||
const exit = yield* Effect.exit(
|
||||
resolved.route.auth.apply({
|
||||
request: LLM.request({ model: resolved, prompt: "Hello" }),
|
||||
method: "POST",
|
||||
url: "https://google.example.com/v1",
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(exit._tag).toBe("Failure")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { ModelRouting } from "@opencode-ai/core/model-routing"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
|
||||
const model = (
|
||||
providerID: string,
|
||||
id: string,
|
||||
input: readonly string[],
|
||||
options: { cost?: number; context?: number; released?: number } = {},
|
||||
) =>
|
||||
Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make(providerID), Model.ID.make(id)),
|
||||
name: id,
|
||||
capabilities: { tools: true, input: [...input], output: ["text"] },
|
||||
time: { released: options.released ?? 1 },
|
||||
cost: [
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(options.cost ?? 1),
|
||||
output: Money.USDPerMillionTokens.make(options.cost ?? 1),
|
||||
cache: { read: Money.USDPerMillionTokens.zero, write: Money.USDPerMillionTokens.zero },
|
||||
},
|
||||
],
|
||||
limit: { context: options.context ?? 100_000, output: 10_000 },
|
||||
})
|
||||
|
||||
describe("ModelRouting.select", () => {
|
||||
test("routes fast work to an inexpensive fast family without selecting haiku", () => {
|
||||
const selected = ModelRouting.select("fast", [
|
||||
model("anthropic", "claude-haiku-4", ["text"], { cost: 0.1, released: 4 }),
|
||||
model("google", "gemini-flash", ["text"], { cost: 0.2, released: 3 }),
|
||||
model("openai", "gpt-5", ["text"], { cost: 2, released: 5 }),
|
||||
])
|
||||
|
||||
expect(selected).toEqual(
|
||||
Model.Ref.make({ providerID: Provider.ID.make("google"), id: Model.ID.make("gemini-flash") }),
|
||||
)
|
||||
})
|
||||
|
||||
test("routes smart work to a high-capability family", () => {
|
||||
const selected = ModelRouting.select("smart", [
|
||||
model("google", "gemini-flash", ["text"], { released: 5 }),
|
||||
model("anthropic", "claude-opus-4", ["text"], { released: 3 }),
|
||||
])
|
||||
|
||||
expect(selected).toEqual(
|
||||
Model.Ref.make({ providerID: Provider.ID.make("anthropic"), id: Model.ID.make("claude-opus-4") }),
|
||||
)
|
||||
})
|
||||
|
||||
test("enforces role capabilities and provider availability through the candidate set", () => {
|
||||
expect(
|
||||
ModelRouting.select("vision", [
|
||||
model("openai", "gpt-5", ["text"], { released: 5 }),
|
||||
model("google", "gemini-pro-vision", ["text", "image"], { released: 3 }),
|
||||
]),
|
||||
).toEqual(Model.Ref.make({ providerID: Provider.ID.make("google"), id: Model.ID.make("gemini-pro-vision") }))
|
||||
|
||||
expect(
|
||||
ModelRouting.select("long-context", [
|
||||
model("openai", "gpt-5", ["text"], { context: 200_000 }),
|
||||
model("google", "gemini-pro", ["text"], { context: 1_000_000 }),
|
||||
]),
|
||||
).toEqual(Model.Ref.make({ providerID: Provider.ID.make("google"), id: Model.ID.make("gemini-pro") }))
|
||||
})
|
||||
|
||||
test("resolves only exact models present in the available catalog", () => {
|
||||
const available = [model("xai", "grok-4", ["text"])]
|
||||
expect(ModelRouting.resolve("xai/grok-4", available)).toEqual(
|
||||
Model.Ref.make({ providerID: Provider.ID.make("xai"), id: Model.ID.make("grok-4") }),
|
||||
)
|
||||
expect(ModelRouting.resolve("xai/grok-5", available)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -47,7 +47,6 @@ const fixtureSnapshot = [
|
||||
info: {
|
||||
id: Provider.ID.make("acme"),
|
||||
name: "Acme",
|
||||
activation: "auto",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
},
|
||||
models: [
|
||||
@@ -110,7 +109,6 @@ const fixture2Snapshot = [
|
||||
info: {
|
||||
id: Provider.ID.make("beta"),
|
||||
name: "Beta",
|
||||
activation: "auto",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
},
|
||||
models: [
|
||||
|
||||
@@ -64,7 +64,6 @@ describe("ModelsDevPlugin", () => {
|
||||
info: {
|
||||
id: providerID,
|
||||
name: "Acme",
|
||||
activation: "auto",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: { baseURL: "https://api.acme.test/v1" },
|
||||
},
|
||||
@@ -240,7 +239,6 @@ describe("ModelsDevPlugin", () => {
|
||||
info: {
|
||||
id: providerID,
|
||||
name: "Acme",
|
||||
activation: "auto",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
},
|
||||
environment: [],
|
||||
@@ -332,7 +330,6 @@ describe("ModelsDevPlugin", () => {
|
||||
info: {
|
||||
id: providerID,
|
||||
name: "Acme",
|
||||
activation: "auto",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: { baseURL: "https://${ACME_HOST}/${UNDECLARED_HOST}/v1" },
|
||||
},
|
||||
@@ -388,7 +385,6 @@ describe("ModelsDevPlugin", () => {
|
||||
info: {
|
||||
id: Provider.ID.make(id),
|
||||
name,
|
||||
activation: "auto",
|
||||
package: Provider.aisdk(packageName),
|
||||
},
|
||||
environment: id === "azure" ? ["AZURE_RESOURCE_NAME", environment] : [environment],
|
||||
|
||||
@@ -60,14 +60,14 @@ describe("LLMGatewayPlugin", () => {
|
||||
})
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(Provider.ID.make("llmgateway"), (provider) => {
|
||||
provider.activation = "disabled"
|
||||
provider.disabled = true
|
||||
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { baseURL: "https://api.llmgateway.io/v1" }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.activation).toBe("disabled")
|
||||
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.disabled).toBe(true)
|
||||
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.headers).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -347,8 +347,6 @@ describe("OpencodePlugin", () => {
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.opencode)).settings?.apiKey).toBe("public")
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.opencode)).activation).toBe("enabled")
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(Provider.ID.opencode)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.opencode, Model.ID.make("free"))).enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -13,7 +13,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
@@ -43,7 +43,7 @@ const readToolNode = makeLocationNode({
|
||||
deps: [
|
||||
Tool.node,
|
||||
ReadToolFileSystem.node,
|
||||
LocationMutation.node,
|
||||
LocationPath.node,
|
||||
Image.node,
|
||||
Permission.node,
|
||||
SessionInstructions.node,
|
||||
@@ -65,7 +65,7 @@ const testLayer = AppNodeBuilder.build(
|
||||
Session.node,
|
||||
Location.node,
|
||||
FSUtil.node,
|
||||
LocationMutation.node,
|
||||
LocationPath.node,
|
||||
ReadToolFileSystem.node,
|
||||
readToolNode,
|
||||
Tool.node,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
@@ -27,7 +27,7 @@ const editToolNode = makeLocationNode({
|
||||
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
|
||||
deps: [
|
||||
Tool.node,
|
||||
LocationMutation.node,
|
||||
LocationPath.node,
|
||||
FileMutation.node,
|
||||
Environment.node,
|
||||
Formatter.node,
|
||||
@@ -84,7 +84,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, editToolNode]),
|
||||
LayerNode.group([Tool.node, Tool.node, LocationPath.node, FileMutation.node, editToolNode]),
|
||||
[
|
||||
[
|
||||
Environment.node,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
@@ -27,7 +27,7 @@ const patchToolNode = makeLocationNode({
|
||||
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
|
||||
deps: [
|
||||
Tool.node,
|
||||
LocationMutation.node,
|
||||
LocationPath.node,
|
||||
FileMutation.node,
|
||||
Environment.node,
|
||||
Formatter.node,
|
||||
@@ -99,7 +99,7 @@ const withTool = <A, E, R>(
|
||||
return yield* body(yield* Tool.Service)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, patchToolNode]), [
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationPath.node, FileMutation.node, patchToolNode]), [
|
||||
[
|
||||
Environment.node,
|
||||
transformEnvironmentFiles(activeLocation, (files) => ({
|
||||
@@ -920,7 +920,7 @@ describe("PatchTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("treats a sibling path inside the project worktree as external to the Location", () =>
|
||||
it.live("treats a sibling path inside the project worktree as internal", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
@@ -939,9 +939,8 @@ describe("PatchTool", () => {
|
||||
call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"),
|
||||
),
|
||||
).toMatchObject({ status: "completed" })
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
||||
expect(assertions[0]?.resources).toEqual([path.join(tmp.path, "*").replaceAll("\\", "/")])
|
||||
expect(assertions[1]?.resources).toEqual([target.replaceAll("\\", "/")])
|
||||
expect(assertions.map((input) => input.action)).toEqual(["edit"])
|
||||
expect(assertions[0]?.resources).toEqual(["../sibling.txt"])
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
|
||||
}),
|
||||
tmp.path,
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||
import { location } from "./fixture/location"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { ReadTool } from "@opencode-ai/core/tool/plugin/read"
|
||||
@@ -32,7 +32,7 @@ const readToolNode = makeLocationNode({
|
||||
deps: [
|
||||
Tool.node,
|
||||
ReadToolFileSystem.node,
|
||||
LocationMutation.node,
|
||||
LocationPath.node,
|
||||
Image.node,
|
||||
Permission.node,
|
||||
SessionInstructions.node,
|
||||
@@ -107,8 +107,8 @@ const locationLayer = Layer.succeed(
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) })),
|
||||
)
|
||||
const mutation = Layer.succeed(
|
||||
LocationMutation.Service,
|
||||
LocationMutation.Service.of({
|
||||
LocationPath.Service,
|
||||
LocationPath.Service.of({
|
||||
resolve: (input) => {
|
||||
const absolute = path.resolve(process.cwd(), input.path)
|
||||
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), absolute)
|
||||
@@ -141,7 +141,7 @@ const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
|
||||
[Permission.node, permission],
|
||||
[Config.node, config],
|
||||
[Image.node, imageLayer],
|
||||
[LocationMutation.node, mutation],
|
||||
[LocationPath.node, mutation],
|
||||
[FSUtil.node, testFileSystem],
|
||||
[Location.node, locationLayer],
|
||||
[Global.node, Global.layerWith({ data: Global.Path.data })],
|
||||
|
||||
@@ -8,7 +8,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -25,12 +25,12 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
|
||||
const globToolNode = makeLocationNode({
|
||||
name: "test/glob-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
|
||||
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
|
||||
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationPath.node, Permission.node],
|
||||
})
|
||||
const grepToolNode = makeLocationNode({
|
||||
name: "test/grep-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
|
||||
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
|
||||
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationPath.node, Permission.node],
|
||||
})
|
||||
const sessionID = Session.ID.make("ses_search_tool_test")
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
@@ -132,7 +132,7 @@ const shellPluginSupervisor = makeLocationNode({
|
||||
deps: [
|
||||
Config.node,
|
||||
Environment.node,
|
||||
LocationMutation.node,
|
||||
LocationPath.node,
|
||||
Permission.node,
|
||||
PluginRuntime.node,
|
||||
Shell.node,
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
@@ -37,7 +36,6 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
|
||||
const childText = "child final response"
|
||||
const childModel = Model.Ref.make({ id: Model.ID.make("child"), providerID: Provider.ID.make("test") })
|
||||
const parentModel = Model.Ref.make({ id: Model.ID.make("parent"), providerID: Provider.ID.make("test") })
|
||||
const fastModel = Model.Ref.make({ id: Model.ID.make("gemini-flash"), providerID: Provider.ID.make("route") })
|
||||
const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
|
||||
const outputSessionID = (value: unknown) =>
|
||||
@@ -102,7 +100,7 @@ const subagentPluginSupervisor = makeLocationNode({
|
||||
PluginSupervisor.Service,
|
||||
registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
|
||||
),
|
||||
deps: [Agent.node, Catalog.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
|
||||
deps: [Agent.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
|
||||
})
|
||||
|
||||
const nodes = LayerNode.group([
|
||||
@@ -144,27 +142,6 @@ const withSubagent = (location: Location.Ref) =>
|
||||
})
|
||||
}),
|
||||
).pipe(Effect.provide(locations.get(location)))
|
||||
yield* Catalog.Service.use((catalog) =>
|
||||
catalog.transform((draft) => {
|
||||
draft.provider.update(fastModel.providerID, (provider) => {
|
||||
provider.activation = "enabled"
|
||||
})
|
||||
draft.model.update(fastModel.providerID, fastModel.id, (model) => {
|
||||
Object.assign(model, Model.Info.default(fastModel.providerID, fastModel.id), {
|
||||
name: "Gemini Flash",
|
||||
family: Model.Family.make("gemini-flash"),
|
||||
time: { released: Date.now() },
|
||||
cost: [
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(0.1),
|
||||
output: Money.USDPerMillionTokens.make(0.2),
|
||||
cache: { read: Money.USDPerMillionTokens.zero, write: Money.USDPerMillionTokens.zero },
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
}),
|
||||
).pipe(Effect.provide(locations.get(location)))
|
||||
})
|
||||
|
||||
describe("SubagentTool", () => {
|
||||
@@ -343,37 +320,6 @@ describe("SubagentTool", () => {
|
||||
})
|
||||
const fallbackChild = yield* sessions.get(outputSessionID(fallback.metadata))
|
||||
expect(fallbackChild).toMatchObject({ parentID: parent.id, model: parentModel })
|
||||
|
||||
const routed = yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-subagent-routed",
|
||||
name: SubagentTool.name,
|
||||
input: { agent: "reviewer", description: "fast", prompt: "quick check", model: "fast" },
|
||||
},
|
||||
})
|
||||
expect(yield* sessions.get(outputSessionID(routed.metadata))).toMatchObject({
|
||||
parentID: parent.id,
|
||||
model: fastModel,
|
||||
})
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-subagent-missing-model",
|
||||
name: SubagentTool.name,
|
||||
input: { agent: "reviewer", description: "missing", prompt: "check", model: "missing/model" },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: "No available model matches route: missing/model" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
@@ -25,7 +25,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||
const writeToolNode = makeLocationNode({
|
||||
name: "test/write-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
|
||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
|
||||
deps: [Tool.node, LocationPath.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_write_tool_test")
|
||||
@@ -72,7 +72,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]),
|
||||
LayerNode.group([Tool.node, Tool.node, LocationPath.node, FileMutation.node, writeToolNode]),
|
||||
[
|
||||
[
|
||||
Environment.node,
|
||||
|
||||
@@ -17728,9 +17728,8 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"activation": {
|
||||
"type": "string",
|
||||
"enum": ["auto", "enabled", "disabled"]
|
||||
"disabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"package": {
|
||||
"type": "string"
|
||||
@@ -17748,7 +17747,7 @@
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": ["id", "name", "activation", "package"],
|
||||
"required": ["id", "name", "package"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ProviderNotFoundError": {
|
||||
|
||||
@@ -25,9 +25,6 @@ export type ID = typeof ID.Type
|
||||
export const Package = Schema.String
|
||||
export type Package = typeof Package.Type
|
||||
|
||||
export const Activation = Schema.Literals(["auto", "enabled", "disabled"])
|
||||
export type Activation = typeof Activation.Type
|
||||
|
||||
export const Overlays = {
|
||||
settings: Schema.Record(Schema.String, Schema.Any).pipe(optional),
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
||||
@@ -49,13 +46,13 @@ export const Info = Schema.Struct({
|
||||
id: ID,
|
||||
integrationID: Integration.ID.pipe(optional),
|
||||
name: Schema.String,
|
||||
activation: Activation,
|
||||
disabled: Schema.Boolean.pipe(optional),
|
||||
package: Package,
|
||||
...Overlays,
|
||||
})
|
||||
.annotate({ identifier: "Provider.Info" })
|
||||
.pipe(
|
||||
statics(() => ({
|
||||
empty: (id: ID): Info => ({ id, name: id, activation: "auto", package: "" }),
|
||||
empty: (id: ID): Info => ({ id, name: id, package: "" }),
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -120,12 +120,10 @@ describe("contract hygiene", () => {
|
||||
test("model defaults and provider overlays preserve public invariants", () => {
|
||||
const id = Model.ID.make("model")
|
||||
expect(Model.Info.default(Provider.ID.make("provider"), id)).toMatchObject({ modelID: id, variants: [] })
|
||||
expect(Provider.Info.empty(Provider.ID.make("provider")).activation).toBe("auto")
|
||||
expect(
|
||||
Schema.decodeUnknownSync(Provider.Info)({
|
||||
id: "provider",
|
||||
name: "Provider",
|
||||
activation: "auto",
|
||||
package: "native",
|
||||
settings: { arbitrary: 1n },
|
||||
}).settings,
|
||||
|
||||
@@ -5,7 +5,6 @@ export function catalogProvider(id: string, name: string): ProviderListOutput["d
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
activation: "auto",
|
||||
package: "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17728,9 +17728,8 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"activation": {
|
||||
"type": "string",
|
||||
"enum": ["auto", "enabled", "disabled"]
|
||||
"disabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"package": {
|
||||
"type": "string"
|
||||
@@ -17748,7 +17747,7 @@
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": ["id", "name", "activation", "package"],
|
||||
"required": ["id", "name", "package"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ProviderNotFoundError": {
|
||||
|
||||
@@ -17728,9 +17728,8 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"activation": {
|
||||
"type": "string",
|
||||
"enum": ["auto", "enabled", "disabled"]
|
||||
"disabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"package": {
|
||||
"type": "string"
|
||||
@@ -17748,7 +17747,7 @@
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": ["id", "name", "activation", "package"],
|
||||
"required": ["id", "name", "package"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ProviderNotFoundError": {
|
||||
|
||||
Reference in New Issue
Block a user