diff --git a/packages/kilo-gateway/src/server/handlers.ts b/packages/kilo-gateway/src/server/handlers.ts index 4e3fca6376..ff8e7f2114 100644 --- a/packages/kilo-gateway/src/server/handlers.ts +++ b/packages/kilo-gateway/src/server/handlers.ts @@ -30,7 +30,7 @@ export interface AuthStore { export interface OrganizationDeps { auth: AuthStore - clear(): void + clear(): void | Promise dispose(): Promise } @@ -97,7 +97,7 @@ export async function setOrganization(deps: OrganizationDeps, organizationId: st ...(organizationId && { accountId: organizationId }), }) - deps.clear() + await deps.clear() clearModesCache() await deps.dispose() return true diff --git a/packages/kilo-gateway/src/server/routes.ts b/packages/kilo-gateway/src/server/routes.ts index 6b21154591..fba7f6f6fa 100644 --- a/packages/kilo-gateway/src/server/routes.ts +++ b/packages/kilo-gateway/src/server/routes.ts @@ -30,7 +30,7 @@ type Validator = any type Resolver = any type Errors = any type Auth = any -type ModelCache = { clear: (providerID: string) => void } +type ModelCache = { clear: (providerID: string) => void | Promise } type Z = any interface KiloRoutesDeps extends ImportDeps { diff --git a/packages/opencode/src/cli/cmd/config.ts b/packages/opencode/src/cli/cmd/config.ts index 746946bff8..5ff58ff2ef 100644 --- a/packages/opencode/src/cli/cmd/config.ts +++ b/packages/opencode/src/cli/cmd/config.ts @@ -1,6 +1,7 @@ // kilocode_change - new file import { EOL } from "os" import { Config } from "../../config/config" +import { AppRuntime } from "../../effect/app-runtime" import { bootstrap } from "../bootstrap" import { cmd } from "./cmd" import { UI } from "../ui" @@ -15,7 +16,7 @@ export const ConfigCommand = cmd({ describe: "check configuration for warnings and errors", async handler() { await bootstrap(process.cwd(), async () => { - const list = await Config.warnings() + const list = await AppRuntime.runPromise(Config.Service.use((svc) => svc.warnings())) if (list.length === 0) { process.stdout.write("No config warnings." + EOL) return diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 3de2161701..0245bd0ef9 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -53,7 +53,6 @@ import { IndexingConfig as KiloIndexingConfig, IndexingSchema as KiloIndexingSchema, } from "@kilocode/kilo-indexing/config" -import { makeRuntime } from "@/effect/run-service" import { unique } from "remeda" // kilocode_change end @@ -1100,44 +1099,4 @@ export const defaultLayer = layer.pipe( Layer.provide(Npm.defaultLayer), ) -// kilocode_change start - keep async wrappers for Kilo callsites during Effect migration -const { runPromise } = makeRuntime(Service, defaultLayer) - -export async function get() { - return runPromise((svc) => svc.get()) -} - -export async function getGlobal() { - return runPromise((svc) => svc.getGlobal()) -} - -export async function getConsoleState() { - return runPromise((svc) => svc.getConsoleState()) -} - -export async function update(config: Info) { - return runPromise((svc) => svc.update(config)) -} - -export async function updateGlobal(config: Info, options?: { dispose?: boolean }) { - return runPromise((svc) => svc.updateGlobal(config, options)) -} - -export async function invalidate() { - return runPromise((svc) => svc.invalidate()) -} - -export async function directories() { - return runPromise((svc) => svc.directories()) -} - -export async function waitForDependencies() { - return runPromise((svc) => svc.waitForDependencies()) -} - -export async function warnings() { - return runPromise((svc) => svc.warnings()) -} -// kilocode_change end - export * as Config from "./config" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 76ed26d302..995942819f 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -15,6 +15,7 @@ import { Storage } from "@/storage/storage" import { Snapshot } from "@/snapshot" import { Plugin } from "@/plugin" import { ModelsDev } from "@/provider/models" +import { ModelCache } from "@/provider/model-cache" // kilocode_change import { Provider } from "@/provider/provider" import { ProviderAuth } from "@/provider/auth" import { Agent } from "@/agent/agent" @@ -68,6 +69,7 @@ export const AppLayer = Layer.mergeAll( Storage.defaultLayer, Snapshot.defaultLayer, Plugin.defaultLayer, + ModelCache.defaultLayer, // kilocode_change ModelsDev.defaultLayer, Provider.defaultLayer, ProviderAuth.defaultLayer, diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 6f51e2179c..47b2d0fa2c 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -56,8 +56,8 @@ if (!process.env[ENV_VERSION]) { process.env[ENV_VERSION] = InstallationVersion } import { Config } from "./config/config" -import { Auth } from "./auth" import { AppRuntime } from "./effect/app-runtime" +import { Auth } from "./auth" // kilocode_change end import { DbCommand } from "./cli/cmd/db" import path from "path" @@ -148,7 +148,7 @@ let cli = yargs(args) // kilocode_change }) // kilocode_change start - Initialize telemetry - const globalCfg = await Config.getGlobal() + const globalCfg = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())) await Telemetry.init({ dataPath: Global.Path.data, version: InstallationVersion, diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index 153005c4e6..20cc36fdb7 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -15,9 +15,11 @@ import { IngestQueue } from "@/kilo-sessions/ingest-queue" import { clearInFlightCache, withInFlightCache } from "@/kilo-sessions/inflight-cache" import type * as SDK from "@kilocode/sdk/v2" import z from "zod" -import { Schema } from "effect" +import { Context, Effect, Layer, Schema, Stream } from "effect" import { KILO_API_BASE } from "@kilocode/kilo-gateway" import { Config } from "@/config/config" +import { EffectBridge } from "@/effect/bridge" +import { InstanceState } from "@/effect/instance-state" import { Instance } from "@/project/instance" import { Vcs } from "@/project/vcs" import simpleGit from "simple-git" @@ -45,6 +47,12 @@ export namespace KiloSessions { ), } + export interface Interface { + readonly init: () => Effect.Effect + } + + export class Service extends Context.Service()("@kilocode/KiloSessions") {} + const log = Log.create({ service: "kilo-sessions" }) const runtime = makeRuntime(Auth.Service, Auth.defaultLayer) @@ -184,131 +192,127 @@ export namespace KiloSessions { await ingest.sync(sessionID, [{ type: "session_status", data: { status } }]) } - export async function init() { - if (ingestDisabled) return + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const bus = yield* Bus.Service + const config = yield* Config.Service + const state = yield* InstanceState.make( + Effect.fn("KiloSessions.state")(function* () { + if (ingestDisabled) return - // kilocode_change start - Same type-erasure upstream uses in share/share-next.ts:165-169. - const watch = (def: D, fn: (evt: { properties: any }) => unknown) => - Bus.subscribe(def as never, fn as never) - // kilocode_change end - - Bus.subscribe(Session.Event.Created, (evt) => { - const sessionId = evt.properties.info.id - void create(sessionId).catch((error) => log.error("share init create failed", { sessionId, error })) - }) - - Bus.subscribe(Session.Event.Updated, async (evt) => { - const sessionID = evt.properties.sessionID // kilocode_change - const session = await Session.get(sessionID).catch(() => null) // kilocode_change - if (!session) return - await ingest.sync(sessionID, [ - { - type: "kilo_meta", - data: await meta(sessionID), - }, - { - type: "session", - data: session, - }, - ]) - }) - - watch(MessageV2.Event.Updated, async (evt) => { - await ingest.sync(evt.properties.info.sessionID, [ - { - type: "message", - data: evt.properties.info, - }, - ]) - - if (evt.properties.info.role === "user") { - await ingest.sync(evt.properties.info.sessionID, [ - { - type: "model", - data: [ - await Provider.getModel(evt.properties.info.model.providerID, evt.properties.info.model.modelID).then( - (m) => m, + const watch = ( + def: D, + fn: (evt: { properties: any }) => unknown | Promise, + ) => + bus.subscribe(def as never).pipe( + Stream.runForEach((evt) => + EffectBridge.fromPromise(() => fn(evt as { properties: any })).pipe( + Effect.catchCause((cause) => + Effect.sync(() => log.error("subscriber failed", { type: def.type, cause })), + ), + ), ), - ], - }, - ]) - } - }) + Effect.forkScoped, + ) - watch(MessageV2.Event.PartUpdated, async (evt) => { - await ingest.sync(evt.properties.part.sessionID, [ - { - type: "part", - data: evt.properties.part, - }, - ]) - }) + yield* watch(Session.Event.Created, (evt) => { + const sessionID = evt.properties.info.id + return create(sessionID).catch((error) => log.error("share init create failed", { sessionID, error })) + }) + yield* watch(Session.Event.Updated, async (evt) => { + const sessionID = evt.properties.sessionID + const session = await Session.get(sessionID).catch(() => null) + if (!session) return + await ingest.sync(sessionID, [ + { type: "kilo_meta", data: await meta(sessionID) }, + { type: "session", data: session }, + ]) + }) + yield* watch(MessageV2.Event.Updated, async (evt) => { + await ingest.sync(evt.properties.info.sessionID, [{ type: "message", data: evt.properties.info }]) + if (evt.properties.info.role !== "user") return + const model = await Provider.getModel( + evt.properties.info.model.providerID, + evt.properties.info.model.modelID, + ) + await ingest.sync(evt.properties.info.sessionID, [{ type: "model", data: [model] }]) + }) + yield* watch(MessageV2.Event.PartUpdated, (evt) => + ingest.sync(evt.properties.part.sessionID, [{ type: "part", data: evt.properties.part }]), + ) + yield* watch(Session.Event.Diff, (evt) => + ingest.sync(evt.properties.sessionID, [{ type: "session_diff", data: evt.properties.diff }]), + ) + yield* watch(Session.Event.TurnOpen, (evt) => + ingest.sync(evt.properties.sessionID, [{ type: "session_open", data: {} }]), + ) + yield* watch(Session.Event.TurnClose, (evt) => + ingest.sync(evt.properties.sessionID, [{ type: "session_close", data: { reason: evt.properties.reason } }]), + ) - watch(Session.Event.Diff, async (evt) => { - await ingest.sync(evt.properties.sessionID, [ - { - type: "session_diff", - data: evt.properties.diff, - }, - ]) - }) + const sync = (evt: { properties: { sessionID: string } }) => { + const sessionID = evt.properties.sessionID + const current = statusSyncs.get(sessionID) + if (current?.running) { + current.dirty = true + return + } - Bus.subscribe(Session.Event.TurnOpen, async (evt) => { - await ingest.sync(evt.properties.sessionID, [{ type: "session_open", data: {} }]) - }) + const entry = current ?? { running: false, dirty: false } + statusSyncs.set(sessionID, entry) - Bus.subscribe(Session.Event.TurnClose, async (evt) => { - await ingest.sync(evt.properties.sessionID, [{ type: "session_close", data: { reason: evt.properties.reason } }]) - }) + const fail = (error: unknown) => { + const dirty = entry.dirty + statusSyncs.delete(sessionID) + log.error("status sync failed", { sessionID, error: String(error) }) + if (dirty) sync(evt) + } - // Session status changes (busy/idle/retry), question lifecycle, permission lifecycle. - const syncStatus = (evt: { properties: { sessionID: string } }) => { - const sessionID = evt.properties.sessionID - const current = statusSyncs.get(sessionID) - if (current?.running) { - current.dirty = true - return - } + const loop = async () => { + entry.running = true + entry.dirty = false + await deriveAndSyncStatus(sessionID) + if (entry.dirty) { + void loop().catch(fail) + return + } + statusSyncs.delete(sessionID) + } - const state = current ?? { running: false, dirty: false } - statusSyncs.set(sessionID, state) + void loop().catch(fail) + } + yield* watch(SessionStatus.Event.Status, sync) + yield* watch(Question.Event.Asked, sync) + yield* watch(Question.Event.Replied, sync) + yield* watch(Question.Event.Rejected, sync) + yield* watch(Permission.Event.Asked, sync) + yield* watch(Permission.Event.Replied, sync) - const fail = (error: unknown) => { - const dirty = state.dirty - statusSyncs.delete(sessionID) - log.error("status sync failed", { sessionID, error: String(error) }) - if (dirty) syncStatus(evt) - } + const cfg = yield* config.getGlobal() + if (remoteEnabled || cfg.remote_control) { + yield* Effect.sync( + () => void enableRemote().catch((err) => log.warn("remote not enabled", { error: String(err) })), + ) + } + yield* Effect.addFinalizer(() => + Effect.sync(() => { + statusSyncs.clear() + disableRemote() + }), + ) + }), + ) - const loop = async () => { - state.running = true - state.dirty = false - await deriveAndSyncStatus(sessionID) - if (state.dirty) { - void loop().catch(fail) - return - } - statusSyncs.delete(sessionID) - } + const init = Effect.fn("KiloSessions.init")(function* () { + yield* InstanceState.get(state) + }) - void loop().catch(fail) - } - Bus.subscribe(SessionStatus.Event.Status, syncStatus) - Bus.subscribe(Question.Event.Asked, syncStatus) - Bus.subscribe(Question.Event.Replied, syncStatus) - Bus.subscribe(Question.Event.Rejected, syncStatus) - Bus.subscribe(Permission.Event.Asked, syncStatus) - Bus.subscribe(Permission.Event.Replied, syncStatus) + return Service.of({ init }) + }), + ) - const cfg = await Config.getGlobal() - if (remoteEnabled || cfg.remote_control) - enableRemote().catch((err) => log.warn("remote not enabled", { error: String(err) })) - // Use wildcard subscription so the dispose handler actually fires — - // Bus.state dispose only notifies "*" subscribers, not event-type ones. - Bus.subscribeAll((evt) => { - if (evt.type === Bus.InstanceDisposed.type) disableRemote() - }) - } + export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer)) export async function enableRemote() { if (remote) return diff --git a/packages/opencode/src/kilocode/agent/index.ts b/packages/opencode/src/kilocode/agent/index.ts index 13fd51f308..93dfb43ebb 100644 --- a/packages/opencode/src/kilocode/agent/index.ts +++ b/packages/opencode/src/kilocode/agent/index.ts @@ -465,8 +465,8 @@ export async function remove(name: string) { let found = false // 1. Delete .md files from config directories - const { Config } = await import("../../config/config") - const dirs = await Config.directories() + const { AppRuntime } = await import("@/effect/app-runtime") + const dirs = await AppRuntime.runPromise(Config.Service.use((svc) => svc.directories())) const patterns = ["{agent,agents}/**/" + name + ".md", "{mode,modes}/" + name + ".md"] for (const dir of dirs) { for (const pattern of patterns) { diff --git a/packages/opencode/src/kilocode/bootstrap.ts b/packages/opencode/src/kilocode/bootstrap.ts index cf4778ed03..63fe2352b8 100644 --- a/packages/opencode/src/kilocode/bootstrap.ts +++ b/packages/opencode/src/kilocode/bootstrap.ts @@ -1,13 +1,37 @@ +import { Cause, Context, Effect, Layer } from "effect" +import { EffectBridge } from "@/effect/bridge" import { KiloSessions } from "@/kilo-sessions/kilo-sessions" import * as Log from "@opencode-ai/core/util/log" const log = Log.create({ service: "kilocode-bootstrap" }) export namespace KilocodeBootstrap { - export async function init() { - await KiloSessions.init() - void import("@/kilocode/indexing") - .then((mod) => mod.KiloIndexing.init()) - .catch((err) => log.warn("indexing bootstrap failed", { err })) + export interface Interface { + readonly init: () => Effect.Effect } + + export class Service extends Context.Service()("@kilocode/Bootstrap") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const sessions = yield* KiloSessions.Service + + const init = Effect.fn("KilocodeBootstrap.init")(function* () { + yield* sessions.init() + yield* EffectBridge.fromPromise(() => + import("@/kilocode/indexing").then((mod) => mod.KiloIndexing.init()), + ).pipe( + Effect.catchCause((cause) => + Effect.sync(() => log.warn("indexing bootstrap failed", { err: Cause.squash(cause) })), + ), + Effect.forkDetach, + ) + }) + + return Service.of({ init }) + }), + ) + + export const defaultLayer = layer.pipe(Layer.provide(KiloSessions.defaultLayer)) } diff --git a/packages/opencode/src/kilocode/config-validation.ts b/packages/opencode/src/kilocode/config-validation.ts index 4ecc3f7650..0b7a7f2ba6 100644 --- a/packages/opencode/src/kilocode/config-validation.ts +++ b/packages/opencode/src/kilocode/config-validation.ts @@ -128,8 +128,9 @@ export namespace ConfigValidation { async function existing(): Promise { try { - const warns = await Config.warnings() - if (!warns || warns.length === 0) return "" + const { AppRuntime } = await import("@/effect/app-runtime") + const warns = await AppRuntime.runPromise(Config.Service.use((svc) => svc.warnings())) + if (warns.length === 0) return "" const items = warns.map((w: Config.Warning) => ` ${label(w.path)}: ${w.message}`).join("\n") return `Pre-existing config issues (from session start):\n${items}\n\n` } catch { @@ -155,7 +156,6 @@ export namespace ConfigValidation { } const prefix = await existing() - const validation = JSONC_EXT.has(ext) ? await jsonc(filepath) : ext === ".md" ? await markdown(filepath) : "" if (!validation) return "" diff --git a/packages/opencode/src/kilocode/indexing-auth.ts b/packages/opencode/src/kilocode/indexing-auth.ts index 1ee17aa211..25c9f7f063 100644 --- a/packages/opencode/src/kilocode/indexing-auth.ts +++ b/packages/opencode/src/kilocode/indexing-auth.ts @@ -1,3 +1,5 @@ +import type { IndexingConfig } from "@kilocode/kilo-indexing/config" + type Auth = unknown type Env = { @@ -106,9 +108,10 @@ export function shouldDefaultIndexingToKilo(indexing: unknown, auth: KiloIndexin return !hasOtherProvider(cfg) } -export function indexingWithKiloDefault(config: unknown, auth: KiloIndexingAuth) { - const cfg = record(config) - const indexing = cfg.indexing +export function indexingWithKiloDefault( + indexing: IndexingConfig | undefined, + auth: KiloIndexingAuth, +): IndexingConfig | undefined { if (!shouldDefaultIndexingToKilo(indexing, auth)) return indexing - return { ...record(indexing), provider: "kilo" } + return { ...indexing, provider: "kilo" } } diff --git a/packages/opencode/src/kilocode/indexing.ts b/packages/opencode/src/kilocode/indexing.ts index 13a1348918..b0bc479ba5 100644 --- a/packages/opencode/src/kilocode/indexing.ts +++ b/packages/opencode/src/kilocode/indexing.ts @@ -13,6 +13,7 @@ import { fetchKiloEmbeddingModelCatalog } from "@kilocode/kilo-gateway" import { Instance } from "@/project/instance" import { Bus } from "@/bus" import { Config } from "@/config/config" +import { AppRuntime } from "@/effect/app-runtime" import { Auth } from "@/auth" import { makeRuntime } from "@/effect/run-service" import { registerDisposer } from "@/effect/instance-registry" @@ -65,7 +66,7 @@ function pending(): z.infer { } } -async function kiloAuth(cfg: Awaited>): Promise { +async function kiloAuth(cfg: Config.Info): Promise { const info = await auth.runPromise((svc) => svc.get("kilo")) return resolveKiloIndexingAuth({ config: cfg, auth: info }) } @@ -221,7 +222,7 @@ export namespace KiloIndexing { const boot = async (hit: Cache): Promise => { const dir = Instance.directory - const cfg = await Config.get() + const cfg = await AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) if (process.env["KILO_DISABLE_CODEBASE_INDEXING"] === "vscode-no-workspace") { return track(hit, await inert(() => noWorkspace())) } @@ -246,12 +247,10 @@ export namespace KiloIndexing { const root = path.join(Global.Path.state, "indexing") const manager = new CodeIndexManager(dir, root) const auth = await kiloAuth(cfg) - const globalConfig = await Config.getGlobal() - const merged = indexingWithKiloDefault( - { ...cfg, indexing: { ...globalConfig.indexing, ...cfg.indexing } }, - auth, - ) as Config.Indexing | undefined - const cfgInput = await model(enrichKilo(input(merged, globalConfig.indexing), auth), auth) + const globalConfig = await AppRuntime.runPromise(Config.Service.use((svc) => svc.getGlobal())) + const global = globalConfig.indexing + const merged = indexingWithKiloDefault({ ...global, ...cfg.indexing }, auth) + const cfgInput = await model(enrichKilo(input(merged, global), auth), auth) const box = { status: pending() as Status | undefined } const current = () => box.status ?? normalizeIndexingStatus(manager) let disposed = false diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts index 27b99522c1..d823b4b10f 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts @@ -44,6 +44,7 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo", Effect.gen(function* () { const auth = yield* Auth.Service const store = yield* InstanceStore.Service + const cache = yield* ModelCache.Service const profile = Effect.fn("KiloGatewayHttpApi.profile")(function* () { const info = yield* auth.get("kilo").pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) @@ -210,7 +211,7 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo", }) .pipe(Effect.mapError(() => new HttpApiError.Unauthorized({}))) - ModelCache.clear("kilo") + yield* cache.clear("kilo") clearModesCache() yield* store.disposeAll().pipe(Effect.mapError(() => new HttpApiError.Unauthorized({}))) return true diff --git a/packages/opencode/src/kilocode/server/instance.ts b/packages/opencode/src/kilocode/server/instance.ts index 660f2d420a..7f427382dc 100644 --- a/packages/opencode/src/kilocode/server/instance.ts +++ b/packages/opencode/src/kilocode/server/instance.ts @@ -63,7 +63,10 @@ export function register(app: Hono): Hono { Bus, SessionCreatedEvent: Session.Event.Created, Identifier, - ModelCache, + ModelCache: { + clear: (providerID: string) => + AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.clear(providerID))), + }, }), ) } diff --git a/packages/opencode/src/kilocode/server/routes/commit-message.ts b/packages/opencode/src/kilocode/server/routes/commit-message.ts index 92237aca3d..965b763c84 100644 --- a/packages/opencode/src/kilocode/server/routes/commit-message.ts +++ b/packages/opencode/src/kilocode/server/routes/commit-message.ts @@ -4,6 +4,7 @@ import { describeRoute, resolver, validator } from "hono-openapi" import z from "zod" import { generateCommitMessage } from "../../commit-message" import { Config } from "../../../config/config" +import { AppRuntime } from "../../../effect/app-runtime" import { lazy } from "../../../util/lazy" import { errors } from "../../../server/error" @@ -39,7 +40,7 @@ export const CommitMessageRoutes = lazy(() => ), async (c) => { const body = c.req.valid("json") - const config = await Config.get() + const config = await AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) const prompt = config.commit_message?.prompt || undefined const result = await generateCommitMessage({ ...body, prompt }) return c.json({ message: result.message }) diff --git a/packages/opencode/src/kilocode/server/server.ts b/packages/opencode/src/kilocode/server/server.ts index 062a6a9ab1..d59785bb95 100644 --- a/packages/opencode/src/kilocode/server/server.ts +++ b/packages/opencode/src/kilocode/server/server.ts @@ -3,6 +3,7 @@ // Imported by ../../server/server.ts with minimal kilocode_change markers. import { ModelCache } from "../../provider/model-cache" +import { AppRuntime } from "../../effect/app-runtime" import { InstanceRuntime } from "../../project/instance-runtime" /** Extra paths to skip request logging for */ @@ -20,7 +21,7 @@ export function corsOrigin(input: string): string | undefined { /** Invalidate model cache and provider state after auth change */ export async function authChanged(providerID: string) { - ModelCache.clear(providerID) + await AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.clear(providerID))) await InstanceRuntime.disposeAllInstances() } diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 842e627574..60a23d4b48 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -218,6 +218,7 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const bus = yield* Bus.Service + const config = yield* Config.Service // kilocode_change const state = yield* InstanceState.make( Effect.fn("Permission.state")(function* (ctx) { const row = Database.use((db) => @@ -360,7 +361,7 @@ export const layer = Layer.effect( action: "allow" as const, })) if (alwaysRules.length > 0) { - yield* Effect.promise(() => Config.updateGlobal({ permission: toConfig(alwaysRules) }, { dispose: false })) + yield* config.updateGlobal({ permission: toConfig(alwaysRules) }, { dispose: false }) } } // kilocode_change end @@ -399,7 +400,7 @@ export const layer = Layer.effect( existing.saved = true // kilocode_change if (newRules.length > 0) { - yield* Effect.promise(() => Config.updateGlobal({ permission: toConfig(newRules) }, { dispose: false })) + yield* config.updateGlobal({ permission: toConfig(newRules) }, { dispose: false }) } yield* drainCovered( @@ -515,7 +516,7 @@ export function disabled(tools: string[], ruleset: Ruleset): Set { return result } -export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) +export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer)) // kilocode_change // kilocode_change start — inverse of fromConfig: convert rules back to config format const SCALAR_ONLY_PERMISSIONS = new Set([ diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index ef82b7f89e..f6086a1752 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -30,6 +30,7 @@ export const layer = Layer.effect( const lsp = yield* LSP.Service const plugin = yield* Plugin.Service const project = yield* Project.Service + const kilocode = yield* KilocodeBootstrap.Service // kilocode_change - Kilo session bootstrap replaces ShareNext // const shareNext = yield* ShareNext.Service // kilocode_change - handled by KilocodeBootstrap const snapshot = yield* Snapshot.Service const vcs = yield* Vcs.Service @@ -41,7 +42,7 @@ export const layer = Layer.effect( yield* config.get() // Plugin can mutate config so it has to be initialized before anything else. yield* plugin.init() - yield* Effect.promise(() => KilocodeBootstrap.init()).pipe(Effect.forkDetach) // kilocode_change + yield* kilocode.init().pipe(Effect.catchCause((cause) => Effect.logWarning("kilocode init failed", { cause }))) // kilocode_change // Each service self-manages its own slow work via Effect.forkScoped against // its per-instance state scope. We just await materialization here. yield* Effect.forEach( @@ -65,6 +66,7 @@ export const defaultLayer: Layer.Layer = layer.pipe( LSP.defaultLayer, Plugin.defaultLayer, Project.defaultLayer, + KilocodeBootstrap.defaultLayer, // kilocode_change - Kilo session bootstrap replaces ShareNext // ShareNext.defaultLayer, // kilocode_change - handled by KilocodeBootstrap Snapshot.defaultLayer, Vcs.defaultLayer, diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index fa06deac93..71cdbc3089 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -111,11 +111,14 @@ interface State { export class Service extends Context.Service()("@opencode/ProviderAuth") {} -export const layer: Layer.Layer = Layer.effect( +// kilocode_change start +export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const auth = yield* Auth.Service const plugin = yield* Plugin.Service + const cache = yield* ModelCache.Service + // kilocode_change end const state = yield* InstanceState.make( Effect.fn("ProviderAuth.state")(function* () { const plugins = yield* plugin.list() @@ -231,7 +234,7 @@ export const layer: Layer.Layer = } } Telemetry.trackAuthSuccess(input.providerID) - ModelCache.clear(input.providerID) + yield* cache.clear(input.providerID) // kilocode_change end }) @@ -239,8 +242,14 @@ export const layer: Layer.Layer = }), ) +// kilocode_change start export const defaultLayer = Layer.suspend(() => - layer.pipe(Layer.provide(Auth.defaultLayer), Layer.provide(Plugin.defaultLayer)), + layer.pipe( + Layer.provide(Auth.defaultLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(ModelCache.defaultLayer), + ), ) +// kilocode_change end export * as ProviderAuth from "./auth" diff --git a/packages/opencode/src/provider/model-cache.ts b/packages/opencode/src/provider/model-cache.ts index a77853b31c..3d452b2d21 100644 --- a/packages/opencode/src/provider/model-cache.ts +++ b/packages/opencode/src/provider/model-cache.ts @@ -1,342 +1,266 @@ // kilocode_change - new file import { fetchKiloModels, type KiloModelsResult } from "@kilocode/kilo-gateway" +import { Context, Duration, Effect, Layer, Schema } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Config } from "../config/config" import { Auth } from "../auth" -import { makeRuntime } from "../effect/run-service" +import type { Provider } from "./models" import * as Log from "@opencode-ai/core/util/log" -export namespace ModelCache { - const log = Log.create({ service: "model-cache" }) - const auth = makeRuntime(Auth.Service, Auth.defaultLayer) - - // Cache structure - const cache = new Map< - string, - { - models: Record - timestamp: number - } - >() - - const TTL = 5 * 60 * 1000 // 5 minutes - const inFlightRefresh = new Map>>() - - // Per-provider failure tracking - const failures = new Map() - - /** - * Get the failure state for a provider (undefined = no failure) - */ - export function getFailure(providerID: string): KiloModelsResult["error"] | undefined { - return failures.get(providerID) - } - - /** - * Get all provider IDs that have a failure state - */ - export function failedProviders(): string[] { - return [...failures.keys()] - } - - /** - * Get cached models if available and not expired - * @param providerID - Provider identifier (e.g., "kilo") - * @returns Cached models or undefined if cache miss or expired - */ - export function get(providerID: string): Record | undefined { - const cached = cache.get(providerID) - - if (!cached) { - log.debug("cache miss", { providerID }) - return undefined - } - - const now = Date.now() - const age = now - cached.timestamp - - if (age > TTL) { - log.debug("cache expired", { providerID, age }) - cache.delete(providerID) - return undefined - } - - log.debug("cache hit", { providerID, age }) - return cached.models - } - - /** - * Fetch models with cache-first approach - * @param providerID - Provider identifier - * @param options - Provider options - * @returns Models from cache or freshly fetched - */ - export async function fetch(providerID: string, options?: any): Promise> { - // Check cache first - const cached = get(providerID) - if (cached) { - return cached - } - - // Cache miss - fetch models - log.info("fetching models", { providerID }) - - const authOptions = await getAuthOptions(providerID).catch((err) => { - log.warn("getAuthOptions failed", { providerID, err }) - return {} - }) - const mergedOptions = { ...authOptions, ...options } - - const result = await fetchModels(providerID, mergedOptions) - const { models } = result - - if (result.error) { - failures.set(providerID, result.error) - log.warn("model fetch error", { providerID, error: result.error }) - } else { - failures.delete(providerID) - } - - // Store in cache (even on error, to avoid hammering the API) - cache.set(providerID, { - models, - timestamp: Date.now(), - }) - - log.info("models fetched and cached", { providerID, count: Object.keys(models).length }) - return models - } - - /** - * Force refresh models (bypass cache) - * Uses atomic refresh pattern to prevent race conditions - * @param providerID - Provider identifier - * @param options - Provider options - * @returns Freshly fetched models - */ - export async function refresh(providerID: string, options?: any): Promise> { - // Check if refresh already in progress - const existing = inFlightRefresh.get(providerID) - if (existing) { - log.debug("refresh already in progress, returning existing promise", { providerID }) - return existing - } - - // Create new refresh promise - const refreshPromise = (async () => { - log.info("refreshing models", { providerID }) - - const authOptions = await getAuthOptions(providerID).catch((err) => { - log.warn("getAuthOptions failed during refresh", { providerID, err }) - return {} - }) - const mergedOptions = { ...authOptions, ...options } - - const result = await fetchModels(providerID, mergedOptions) - const { models } = result - - if (result.error) { - failures.set(providerID, result.error) - log.warn("model refresh error", { providerID, error: result.error }) - } else { - failures.delete(providerID) - } - - cache.set(providerID, { - models, - timestamp: Date.now(), - }) - - log.info("models refreshed", { providerID, count: Object.keys(models).length }) - return models - })() - - // Track in-flight refresh - inFlightRefresh.set(providerID, refreshPromise) - - try { - return await refreshPromise - } finally { - // Clean up in-flight tracking - inFlightRefresh.delete(providerID) - } - } - - /** - * Clear cached models for a provider - * @param providerID - Provider identifier - */ - export function clear(providerID: string): void { - const deleted = cache.delete(providerID) - failures.delete(providerID) - if (deleted) { - log.info("cache cleared", { providerID }) - } else { - log.debug("no cache to clear", { providerID }) - } - } - - /** - * Fetch models based on provider type - * @param providerID - Provider identifier - * @param options - Provider options - * @returns Fetched models - */ - async function fetchModels(providerID: string, options: any): Promise { - if (providerID === "kilo") { - return fetchKiloModels(options) - } - - // kilocode_change start - if (providerID === "apertis") { - const models = await fetchApertisModels(options) - return { models } - } - // kilocode_change end - - // Other providers not implemented yet - log.debug("provider not implemented", { providerID }) - return { models: {} } - } - - // kilocode_change start - const APERTIS_BASE_URL = "https://api.apertis.ai/v1" - - async function fetchApertisModels(options: any): Promise> { - const baseURL = options.baseURL ?? APERTIS_BASE_URL - const apiKey = options.apiKey - - if (!apiKey) { - log.debug("no API key for apertis, skipping model fetch") - return {} - } - - const url = `${baseURL.replace(/\/+$/, "")}/models` - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${apiKey}`, - }, - signal: AbortSignal.timeout(10_000), - }) - - if (!response.ok) { - log.error("apertis model fetch failed", { status: response.status }) - return {} - } - - const json = (await response.json()) as { data?: Array<{ id: string; owned_by?: string }> } - const models: Record = {} - - for (const model of json.data ?? []) { - models[model.id] = { - id: model.id, - name: model.id, - family: model.owned_by ?? "", - release_date: "", - attachment: true, - reasoning: false, - temperature: true, - tool_call: true, - cost: { input: 0, output: 0 }, - limit: { context: 128000, output: 4096 }, - options: {}, - modalities: { - input: ["text", "image"], - output: ["text"], - }, - } - } - - return models - } - // kilocode_change end - - /** - * Get authentication options from multiple sources - * Priority: Config > Auth > Env - * @param providerID - Provider identifier - * @returns Options object with authentication credentials - */ - async function getAuthOptions(providerID: string): Promise { - const options: any = {} - const getAuth = (id: string) => auth.runPromise((svc) => svc.get(id)) - - if (providerID === "kilo") { - // Get from Config - const config = await Config.get() - const providerConfig = config.provider?.[providerID] - if (providerConfig?.options?.apiKey) { - options.kilocodeToken = providerConfig.options.apiKey - } - - // kilocode_change start - if (providerConfig?.options?.kilocodeOrganizationId) { - options.kilocodeOrganizationId = providerConfig.options.kilocodeOrganizationId - } - // kilocode_change end - - // Get from Auth - const auth = await getAuth(providerID) - if (auth) { - if (auth.type === "api") { - options.kilocodeToken = auth.key - } else if (auth.type === "oauth") { - options.kilocodeToken = auth.access - // kilocode_change start - read org ID from OAuth accountId for enterprise model filtering - if (auth.accountId) { - options.kilocodeOrganizationId = auth.accountId - } - // kilocode_change end - } - } - - // Get from Env (process.env — matches upstream's pattern for sync async helpers) - const env = process.env - if (env.KILO_API_KEY) { - options.kilocodeToken = env.KILO_API_KEY - } - if (env.KILO_ORG_ID) { - options.kilocodeOrganizationId = env.KILO_ORG_ID - } - - log.debug("auth options resolved", { - providerID, - hasToken: !!options.kilocodeToken, - hasOrganizationId: !!options.kilocodeOrganizationId, - }) - } - - // kilocode_change start - if (providerID === "apertis") { - const config = await Config.get() - const providerConfig = config.provider?.[providerID] - if (providerConfig?.options?.apiKey) { - options.apiKey = providerConfig.options.apiKey - } - if (providerConfig?.options?.baseURL) { - options.baseURL = providerConfig.options.baseURL - } - - const auth = await getAuth(providerID) - if (auth && auth.type === "api") { - options.apiKey = auth.key - } - - const env = process.env - if (env.APERTIS_API_KEY) { - options.apiKey = env.APERTIS_API_KEY - } - if (env.APERTIS_BASE_URL) { - options.baseURL = env.APERTIS_BASE_URL - } - - log.debug("apertis auth options resolved", { - providerID, - hasKey: !!options.apiKey, - hasBaseURL: !!options.baseURL, - }) - } - // kilocode_change end - - return options - } +type Models = Provider["models"] +type KiloOptions = NonNullable[0]> +type Options = { -readonly [K in keyof KiloOptions]?: KiloOptions[K] } & { apiKey?: string } +type Failure = NonNullable +type Result = { readonly models: Models; readonly error?: Failure } +type View = { models?: Models; timestamp?: number } +type Cell = { + readonly providerID: string + readonly view: View + readonly cached: Effect.Effect + readonly invalidate: Effect.Effect } + +export interface Interface { + readonly getFailure: (providerID: string) => Effect.Effect + readonly failedProviders: () => Effect.Effect + readonly get: (providerID: string) => Effect.Effect + readonly fetch: (providerID: string, options?: Options) => Effect.Effect + readonly refresh: (providerID: string, options?: Options) => Effect.Effect + readonly clear: (providerID: string) => Effect.Effect +} + +export class Service extends Context.Service()("@kilocode/ModelCache") {} + +const log = Log.create({ service: "model-cache" }) +const ttl = Duration.minutes(5) +const APERTIS_BASE_URL = "https://api.apertis.ai/v1" +const ApertisItem = Schema.Struct({ id: Schema.String, owned_by: Schema.optional(Schema.String) }) +const ApertisResponse = Schema.Struct({ data: Schema.optional(Schema.Array(ApertisItem)) }) +type ApertisItem = Schema.Schema.Type + +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const auth = yield* Auth.Service + const cfg = yield* Config.Service + const http = yield* HttpClient.HttpClient + const cells = new Map() + const active = new Map() + const versions = new Map() + const failures = new Map() + + const getFailure = Effect.fn("ModelCache.getFailure")(function* (providerID: string) { + return failures.get(providerID) + }) + + const failedProviders = Effect.fn("ModelCache.failedProviders")(function* () { + return [...failures.keys()] + }) + + const aperture = (item: ApertisItem): Models[string] => ({ + id: item.id, + name: item.id, + family: item.owned_by ?? "", + release_date: "", + attachment: true, + reasoning: false, + temperature: true, + tool_call: true, + cost: { input: 0, output: 0 }, + limit: { context: 128000, output: 4096 }, + modalities: { input: ["text", "image"], output: ["text"] }, + }) + + const fetchApertisModels = Effect.fn("ModelCache.fetchApertisModels")(function* (options: Options) { + const baseURL = options.baseURL ?? APERTIS_BASE_URL + if (!options.apiKey) { + log.debug("no API key for apertis, skipping model fetch") + return {} + } + + const url = `${baseURL.replace(/\/+$/, "")}/models` + const response = yield* HttpClientRequest.get(url).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(options.apiKey), + http.execute, + Effect.timeout("10 seconds"), + ) + if (response.status < 200 || response.status >= 300) { + log.error("apertis model fetch failed", { status: response.status }) + return {} + } + + const json = yield* HttpClientResponse.schemaBodyJson(ApertisResponse)(response) + return Object.fromEntries((json.data ?? []).map((item) => [item.id, aperture(item)])) + }) + + const authOptions = Effect.fn("ModelCache.authOptions")(function* (providerID: string) { + if (providerID !== "kilo" && providerID !== "apertis") return {} + const config = yield* cfg.get() + const options: Options = {} + + if (providerID === "kilo") { + const item = config.provider?.[providerID] + if (item?.options?.apiKey) options.kilocodeToken = item.options.apiKey + if (item?.options?.kilocodeOrganizationId) options.kilocodeOrganizationId = item.options.kilocodeOrganizationId + + const info = yield* auth.get(providerID) + if (info?.type === "api") options.kilocodeToken = info.key + if (info?.type === "oauth") { + options.kilocodeToken = info.access + if (info.accountId) options.kilocodeOrganizationId = info.accountId + } + + if (process.env.KILO_API_KEY) options.kilocodeToken = process.env.KILO_API_KEY + if (process.env.KILO_ORG_ID) options.kilocodeOrganizationId = process.env.KILO_ORG_ID + log.debug("auth options resolved", { + providerID, + hasToken: !!options.kilocodeToken, + hasOrganizationId: !!options.kilocodeOrganizationId, + }) + } + + if (providerID === "apertis") { + const item = config.provider?.[providerID] + if (item?.options?.apiKey) options.apiKey = item.options.apiKey + if (item?.options?.baseURL) options.baseURL = item.options.baseURL + + const info = yield* auth.get(providerID) + if (info?.type === "api") options.apiKey = info.key + if (process.env.APERTIS_API_KEY) options.apiKey = process.env.APERTIS_API_KEY + if (process.env.APERTIS_BASE_URL) options.baseURL = process.env.APERTIS_BASE_URL + log.debug("apertis auth options resolved", { + providerID, + hasKey: !!options.apiKey, + hasBaseURL: !!options.baseURL, + }) + } + + return options + }) + + const fetchModels = (providerID: string, options: Options): Effect.Effect => { + if (providerID === "kilo") return Effect.tryPromise(() => fetchKiloModels(options)) + if (providerID === "apertis") return fetchApertisModels(options).pipe(Effect.map((models) => ({ models }))) + log.debug("provider not implemented", { providerID }) + return Effect.succeed({ models: {} }) + } + + const load = Effect.fn("ModelCache.load")(function* (providerID: string, options: Options) { + const resolved = yield* authOptions(providerID).pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + log.warn("auth options failed", { providerID, cause }) + return {} + }), + ), + ) + return yield* fetchModels(providerID, { ...resolved, ...options }) + }) + + const key = (providerID: string, options?: Options) => { + if (providerID === "kilo") { + return JSON.stringify([providerID, options?.baseURL, options?.kilocodeOrganizationId, options?.kilocodeToken]) + } + if (providerID === "apertis") return JSON.stringify([providerID, options?.baseURL, options?.apiKey]) + return providerID + } + + const cell = Effect.fn("ModelCache.cell")(function* (providerID: string, options: Options = {}) { + const id = key(providerID, options) + const existing = cells.get(id) + if (existing) return existing + const view: View = {} + const [cached, invalidate] = yield* Effect.cachedInvalidateWithTTL(load(providerID, options), ttl) + const next = { providerID, view, cached, invalidate } + cells.set(id, next) + return next + }) + + // Failed loads are not cached so a temporary outage can recover on the next read. + const evaluate = (entry: Cell) => entry.cached.pipe(Effect.tapCause(() => entry.invalidate)) + + const commit = (providerID: string, version: number, entry: Cell, result: Result) => + Effect.sync(() => { + if ((versions.get(providerID) ?? 0) !== version) return result.models + if (result.error) { + failures.set(providerID, result.error) + log.warn("model fetch error", { providerID, error: result.error }) + } else { + failures.delete(providerID) + } + entry.view.models = result.models + entry.view.timestamp = Date.now() + active.set(providerID, entry) + log.info("models fetched and cached", { providerID, count: Object.keys(result.models).length }) + return result.models + }) + + const get = Effect.fn("ModelCache.get")(function* (providerID: string) { + const entry = active.get(providerID) + if (!entry?.view.models || entry.view.timestamp === undefined) { + log.debug("cache miss", { providerID }) + return + } + + const age = Date.now() - entry.view.timestamp + if (age > Duration.toMillis(ttl)) { + log.debug("cache expired", { providerID, age }) + entry.view.models = undefined + entry.view.timestamp = undefined + yield* entry.invalidate + return + } + + log.debug("cache hit", { providerID, age }) + return entry.view.models + }) + + const fetch = Effect.fn("ModelCache.fetch")(function* (providerID: string, options?: Options) { + const cached = yield* get(providerID) + if (cached) return cached + const version = (versions.get(providerID) ?? 0) + 1 + versions.set(providerID, version) + const entry = yield* cell(providerID, options) + log.info("fetching models", { providerID }) + const result = yield* evaluate(entry) + return yield* commit(providerID, version, entry, result) + }) + + const refresh = Effect.fn("ModelCache.refresh")(function* (providerID: string, options?: Options) { + const version = (versions.get(providerID) ?? 0) + 1 + versions.set(providerID, version) + const entry = yield* cell(providerID, options) + log.info("refreshing models", { providerID }) + yield* entry.invalidate + const result = yield* evaluate(entry) + return yield* commit(providerID, version, entry, result) + }) + + const clear = Effect.fn("ModelCache.clear")(function* (providerID: string) { + versions.set(providerID, (versions.get(providerID) ?? 0) + 1) + const entries = [...cells.entries()].filter(([, entry]) => entry.providerID === providerID) + yield* Effect.all( + entries.map(([id, entry]) => entry.invalidate.pipe(Effect.tap(() => Effect.sync(() => cells.delete(id))))), + { discard: true }, + ) + active.delete(providerID) + failures.delete(providerID) + if (entries.some(([, entry]) => entry.view.models)) { + log.info("cache cleared", { providerID }) + return + } + log.debug("no cache to clear", { providerID }) + }) + + return Service.of({ getFailure, failedProviders, get, fetch, refresh, clear }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(Auth.defaultLayer), + Layer.provide(Config.defaultLayer), +) + +export * as ModelCache from "./model-cache" diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 82d75360a2..e3ef43fd60 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -123,11 +123,15 @@ export interface Interface { export class Service extends Context.Service()("@opencode/ModelsDev") {} -export const layer: Layer.Layer = Layer.effect( // kilocode_change +type Requirements = AppFileSystem.Service | HttpClient.HttpClient | Config.Service | Auth.Service | ModelCache.Service // kilocode_change + +export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* AppFileSystem.Service + const cfg = yield* Config.Service // kilocode_change const auth = yield* Auth.Service // kilocode_change + const cache = yield* ModelCache.Service // kilocode_change const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) const source = Flag.KILO_MODELS_URL || "https://models.dev" @@ -195,7 +199,7 @@ export const layer: Layer.Layer Config.get()) + const config = yield* cfg.get() const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined const kiloAllowed = (!enabled || enabled.has("kilo")) && !disabled.has("kilo") @@ -207,9 +211,8 @@ export const layer: Layer.Layer Effect.succeed(undefined))) const org = opts?.kilocodeOrganizationId ?? (info?.type === "oauth" ? info.accountId : undefined) - const base = normalizeKiloBaseURL(opts?.baseURL, org) const fetch = { ...(base ? { baseURL: base } : {}), @@ -217,10 +220,10 @@ export const layer: Layer.Layer ModelCache.fetch("kilo", fetch).catch(() => ({}))), + cache.fetch("kilo", fetch).pipe(Effect.catch(() => Effect.succeed({}))), providers["apertis"] ? Effect.succeed(null) - : Effect.promise(() => ModelCache.fetch("apertis", aptFetch).catch(() => ({}))), + : cache.fetch("apertis", aptFetch).pipe(Effect.catch(() => Effect.succeed({}))), ], { concurrency: 2 }, ) @@ -234,7 +237,7 @@ export const layer: Layer.Layer void ModelCache.refresh("kilo", fetch).catch(() => {})) + yield* cache.refresh("kilo", fetch).pipe(Effect.ignore, Effect.forkDetach) } if (!providers["apertis"] && apertis !== null) { providers["apertis"] = { @@ -246,14 +249,14 @@ export const layer: Layer.Layer void ModelCache.refresh("apertis", aptFetch).catch(() => {})) + yield* cache.refresh("apertis", aptFetch).pipe(Effect.ignore, Effect.forkDetach) } } return providers } if (!providers["apertis"]) { - const apertis = yield* Effect.promise(() => ModelCache.fetch("apertis", aptFetch).catch(() => ({}))) + const apertis = yield* cache.fetch("apertis", aptFetch).pipe(Effect.catch(() => Effect.succeed({}))) providers["apertis"] = { id: "apertis", name: "Apertis", @@ -263,7 +266,7 @@ export const layer: Layer.Layer void ModelCache.refresh("apertis", aptFetch).catch(() => {})) + yield* cache.refresh("apertis", aptFetch).pipe(Effect.ignore, Effect.forkDetach) } } return providers @@ -299,7 +302,9 @@ export const layer: Layer.Layer = layer.pipe( Layer.provide(FetchHttpClient.layer), Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Config.defaultLayer), // kilocode_change Layer.provide(Auth.defaultLayer), // kilocode_change + Layer.provide(ModelCache.defaultLayer), // kilocode_change ) export * as ModelsDev from "./models" diff --git a/packages/opencode/src/server/routes/instance/config.ts b/packages/opencode/src/server/routes/instance/config.ts index 12187a4463..6fcb075a37 100644 --- a/packages/opencode/src/server/routes/instance/config.ts +++ b/packages/opencode/src/server/routes/instance/config.ts @@ -102,9 +102,11 @@ export const ConfigRoutes = lazy(() => }, }, }), - async (c) => { - return c.json(await Config.warnings()) - }, + async (c) => + jsonRequest("ConfigRoutes.warnings", c, function* () { + const cfg = yield* Config.Service + return yield* cfg.warnings() + }), ) // kilocode_change end .get( diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts index 80adbe59ac..7fd7a626ea 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -16,6 +16,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" const cfg = yield* Config.Service const provider = yield* Provider.Service const svc = yield* ProviderAuth.Service + const cache = yield* ModelCache.Service // kilocode_change const list = Effect.fn("ProviderHttpApi.list")(function* () { const config = yield* cfg.get() @@ -32,7 +33,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" connected, ) // kilocode_change start - const failed = ModelCache.failedProviders() + const failed = yield* cache.failedProviders() // Note: connected only contains providers with non-empty models after Provider.Service.list(), // so failed must be checked explicitly for providers whose fetch returned an error. const failedSet = new Set(failed) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 074d130084..e821b94e5f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -24,6 +24,7 @@ import { Plugin } from "@/plugin" import { Project } from "@/project/project" import { ProviderAuth } from "@/provider/auth" import { ModelsDev } from "@/provider/models" +import { ModelCache } from "@/provider/model-cache" // kilocode_change import { Provider } from "@/provider/provider" import { Pty } from "@/pty" import { PtyTicket } from "@/pty/ticket" @@ -163,6 +164,7 @@ export function createRoutes(corsOptions?: CorsOptions) { LSP.defaultLayer, Installation.defaultLayer, MCP.defaultLayer, + ModelCache.defaultLayer, // kilocode_change ModelsDev.defaultLayer, Permission.defaultLayer, Plugin.defaultLayer, diff --git a/packages/opencode/src/server/routes/instance/provider.ts b/packages/opencode/src/server/routes/instance/provider.ts index bbf8c49898..fae1460e58 100644 --- a/packages/opencode/src/server/routes/instance/provider.ts +++ b/packages/opencode/src/server/routes/instance/provider.ts @@ -37,6 +37,7 @@ export const ProviderRoutes = lazy(() => jsonRequest("ProviderRoutes.list", c, function* () { const svc = yield* Provider.Service const cfg = yield* Config.Service + const cache = yield* ModelCache.Service // kilocode_change const config = yield* cfg.get() const all = yield* ModelsDev.Service.use((s) => s.get()) const disabled = new Set(config.disabled_providers ?? []) @@ -53,7 +54,7 @@ export const ProviderRoutes = lazy(() => connected, ) // kilocode_change start - const failed = ModelCache.failedProviders() + const failed = yield* cache.failedProviders() // Keep connected or failed providers even when they have 0 models so /connect can re-auth them. // Note: connected only contains providers whose model list is non-empty after Provider.Service.list(), // so failed must be checked explicitly for providers whose fetch returned an error. diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 51b4076e6e..0a6ed16453 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -78,6 +78,10 @@ const clear = async (wait = false) => { } const listDirs = () => Effect.runPromise(Config.Service.use((svc) => svc.directories()).pipe(Effect.scoped, Effect.provide(layer))) +// kilocode_change start +const warnings = () => + Effect.runPromise(Config.Service.use((svc) => svc.warnings()).pipe(Effect.scoped, Effect.provide(layer))) +// kilocode_change end const ready = () => Effect.runPromise(Config.Service.use((svc) => svc.waitForDependencies()).pipe(Effect.scoped, Effect.provide(layer))) @@ -386,6 +390,7 @@ test("jsonc overrides json in the same directory", async () => { }) }) +// kilocode_change start test("prefers .kilo directory config over legacy .kilocode", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -409,11 +414,12 @@ test("prefers .kilo directory config over legacy .kilocode", async () => { await WithInstance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.model).toBe("new/model") }, }) }) +// kilocode_change end test("handles environment variable substitution", async () => { const originalEnv = process.env["TEST_VAR"] @@ -585,6 +591,7 @@ test("handles file inclusion with replacement tokens", async () => { }) }) +// kilocode_change start test("validates config schema and reports warning on invalid fields", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -597,14 +604,16 @@ test("validates config schema and reports warning on invalid fields", async () = await provideTestInstance({ directory: tmp.path, fn: async () => { - // kilocode_change - invalid schema surfaces as warnings, not a throw + // invalid schema surfaces as warnings, not a throw await load() - const warnings = await Config.warnings() - expect(warnings.length).toBeGreaterThan(0) + const issues = await warnings() + expect(issues.length).toBeGreaterThan(0) }, }) }) +// kilocode_change end +// kilocode_change start test("reports warning for invalid JSON", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -614,13 +623,14 @@ test("reports warning for invalid JSON", async () => { await provideTestInstance({ directory: tmp.path, fn: async () => { - // kilocode_change - invalid JSON surfaces as a warning, not a throw + // invalid JSON surfaces as a warning, not a throw await load() - const warnings = await Config.warnings() - expect(warnings.length).toBeGreaterThan(0) + const issues = await warnings() + expect(issues.length).toBeGreaterThan(0) }, }) }) +// kilocode_change end test("handles agent configuration", async () => { await using tmp = await tmpdir({ @@ -965,6 +975,7 @@ Nested command template`, }) }) +// kilocode_change start test("prefers .kilo commands over legacy .kilocode commands", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -988,7 +999,7 @@ Hello from new command`, await WithInstance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.command?.["hello"]).toEqual({ description: "New command", @@ -997,6 +1008,7 @@ Hello from new command`, }, }) }) +// kilocode_change end test("gets config directories", async () => { await using tmp = await tmpdir() diff --git a/packages/opencode/test/kilocode/config-resilience.test.ts b/packages/opencode/test/kilocode/config-resilience.test.ts index c1595f1e46..e49e510bc4 100644 --- a/packages/opencode/test/kilocode/config-resilience.test.ts +++ b/packages/opencode/test/kilocode/config-resilience.test.ts @@ -1,13 +1,17 @@ import { afterEach, describe, expect, test } from "bun:test" import path from "path" import { Config } from "../../src/config/config" +import { AppRuntime } from "../../src/effect/app-runtime" import { WithInstance } from "../../src/project/with-instance" import { Filesystem } from "../../src/util/filesystem" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) +const warnings = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.warnings())) + afterEach(async () => { await disposeAllInstances() - await Config.invalidate() + await AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate())) }) describe("config resilience", () => { @@ -34,7 +38,7 @@ Valid agent prompt`, await WithInstance.provide({ directory: tmp.path, fn: async () => { - const cfg = await Config.get() + const cfg = await load() expect(cfg.agent?.["skip"]).toBeUndefined() expect(cfg.agent?.["keep"]).toMatchObject({ @@ -62,8 +66,8 @@ Broken agent prompt`, await WithInstance.provide({ directory: tmp.path, fn: async () => { - await Config.get() - const warns = await Config.warnings() + await load() + const warns = await warnings() expect(warns.some((w) => w.path.includes("skip.md") && w.message.includes("mode"))).toBe(true) }, @@ -93,7 +97,7 @@ Valid command template`, await WithInstance.provide({ directory: tmp.path, fn: async () => { - const cfg = await Config.get() + const cfg = await load() expect(cfg.command?.["skip"]).toBeUndefined() expect(cfg.command?.["keep"]).toEqual({ @@ -120,8 +124,8 @@ Broken command template`, await WithInstance.provide({ directory: tmp.path, fn: async () => { - await Config.get() - const warns = await Config.warnings() + await load() + const warns = await warnings() expect(warns.some((w) => w.path.includes("skip.md") && w.message.includes("subtask"))).toBe(true) }, @@ -144,8 +148,8 @@ Broken agent`, await WithInstance.provide({ directory: tmp.path, fn: async () => { - await Config.get() - const warns = await Config.warnings() + await load() + const warns = await warnings() expect(warns.some((w) => w.path.includes("broken.md") && w.message.includes("invalid"))).toBe(true) }, @@ -168,8 +172,8 @@ Broken command`, await WithInstance.provide({ directory: tmp.path, fn: async () => { - await Config.get() - const warns = await Config.warnings() + await load() + const warns = await warnings() expect(warns.some((w) => w.path.includes("broken.md") && w.message.includes("invalid"))).toBe(true) }, @@ -186,8 +190,8 @@ Broken command`, await WithInstance.provide({ directory: tmp.path, fn: async () => { - const cfg = await Config.get() - const warns = await Config.warnings() + const cfg = await load() + const warns = await warnings() // Config loading should not crash expect(cfg).toBeDefined() @@ -207,8 +211,8 @@ Broken command`, await WithInstance.provide({ directory: tmp.path, fn: async () => { - const cfg = await Config.get() - const warns = await Config.warnings() + const cfg = await load() + const warns = await warnings() expect(cfg).toBeDefined() expect(warns.some((w) => w.path.includes("kilo.json") && w.message.includes("invalid"))).toBe(true) @@ -224,8 +228,8 @@ Broken command`, await WithInstance.provide({ directory: tmp.path, fn: async () => { - await Config.get() - const warns = await Config.warnings() + await load() + const warns = await warnings() expect(warns).toEqual([]) }, diff --git a/packages/opencode/test/kilocode/config-validation.test.ts b/packages/opencode/test/kilocode/config-validation.test.ts index 2903d638d7..8cfce32259 100644 --- a/packages/opencode/test/kilocode/config-validation.test.ts +++ b/packages/opencode/test/kilocode/config-validation.test.ts @@ -4,6 +4,7 @@ import path from "path" import { ConfigValidation } from "../../src/kilocode/config-validation" import { WithInstance } from "../../src/project/with-instance" import { Config } from "../../src/config/config" +import { AppRuntime } from "../../src/effect/app-runtime" import { Filesystem } from "../../src/util/filesystem" import { disposeAllInstances, tmpdir } from "../fixture/fixture" @@ -11,6 +12,8 @@ afterEach(async () => { await disposeAllInstances() }) +const check = (filepath: string) => ConfigValidation.check(filepath) + describe("ConfigValidation.check", () => { test("returns empty string for non-config files", async () => { await using tmp = await tmpdir({ git: true }) @@ -19,7 +22,7 @@ describe("ConfigValidation.check", () => { const result = await WithInstance.provide({ directory: tmp.path, - fn: () => ConfigValidation.check(filepath), + fn: () => check(filepath), }) expect(result).toBe("") }) @@ -31,7 +34,7 @@ describe("ConfigValidation.check", () => { const result = await WithInstance.provide({ directory: tmp.path, - fn: () => ConfigValidation.check(filepath), + fn: () => check(filepath), }) expect(result).toContain("config_validation") expect(result).toContain("validated successfully") @@ -44,7 +47,7 @@ describe("ConfigValidation.check", () => { const result = await WithInstance.provide({ directory: tmp.path, - fn: () => ConfigValidation.check(filepath), + fn: () => check(filepath), }) expect(result).toContain("config_validation") expect(result).toContain("ERROR") @@ -59,7 +62,7 @@ describe("ConfigValidation.check", () => { const result = await WithInstance.provide({ directory: tmp.path, - fn: () => ConfigValidation.check(filepath), + fn: () => check(filepath), }) expect(result).toContain("config_validation") expect(result).toContain("WARNING") @@ -79,7 +82,7 @@ Do something useful`, const result = await WithInstance.provide({ directory: tmp.path, - fn: () => ConfigValidation.check(filepath), + fn: () => check(filepath), }) expect(result).toContain("config_validation") expect(result).toContain("validated successfully") @@ -100,7 +103,7 @@ Do something`, const result = await WithInstance.provide({ directory: tmp.path, - fn: () => ConfigValidation.check(filepath), + fn: () => check(filepath), }) expect(result).toContain("config_validation") expect(result).toContain("WARNING") @@ -121,7 +124,7 @@ You are a helpful agent.`, const result = await WithInstance.provide({ directory: tmp.path, - fn: () => ConfigValidation.check(filepath), + fn: () => check(filepath), }) expect(result).toContain("config_validation") expect(result).toContain("validated successfully") @@ -134,7 +137,7 @@ You are a helpful agent.`, const result = await WithInstance.provide({ directory: tmp.path, - fn: () => ConfigValidation.check(filepath), + fn: () => check(filepath), }) expect(result).toBe("") }) @@ -146,7 +149,7 @@ You are a helpful agent.`, const result = await WithInstance.provide({ directory: tmp.path, - fn: () => ConfigValidation.check(filepath), + fn: () => check(filepath), }) expect(result).toBe("") }) @@ -173,8 +176,8 @@ Broken agent`, directory: tmp.path, fn: async () => { // Force config load to populate warnings - await Config.get() - return ConfigValidation.check(filepath) + await AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) + return check(filepath) }, }) expect(result).toContain("Pre-existing config issues") diff --git a/packages/opencode/test/kilocode/kilo-loader-auth.test.ts b/packages/opencode/test/kilocode/kilo-loader-auth.test.ts index 41e092403a..cabff0e145 100644 --- a/packages/opencode/test/kilocode/kilo-loader-auth.test.ts +++ b/packages/opencode/test/kilocode/kilo-loader-auth.test.ts @@ -41,6 +41,9 @@ import { Provider } from "../../src/provider/provider" import { ProviderID } from "../../src/provider/schema" import { Filesystem } from "../../src/util/filesystem" import { ModelCache } from "../../src/provider/model-cache" +import { AppRuntime } from "../../src/effect/app-runtime" + +const clear = (id: string) => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.clear(id))) function paid(providers: Awaited>) { const item = providers[ProviderID.kilo] @@ -51,13 +54,12 @@ function paid(providers: Awaited>) { const authPath = path.join(Global.Path.data, "auth.json") test("kilo loader keeps paid models without auth and when config apiKey is present", async () => { - // Reset state that may be stale from other test files sharing this process. // Persisted auth from other tests and ModelCache's TTL map must not affect this test. const prev = await Filesystem.readText(authPath).catch(() => undefined) try { await Filesystem.write(authPath, JSON.stringify({})) - ModelCache.clear("kilo") + await clear("kilo") await using base = await tmpdir({ init: async (dir) => { @@ -115,7 +117,7 @@ test("kilo loader keeps paid models without auth and when auth exists", async () try { await Filesystem.write(authPath, JSON.stringify({})) - ModelCache.clear("kilo") + await clear("kilo") await using base = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/kilocode/kilo-models-401-fallback.test.ts b/packages/opencode/test/kilocode/kilo-models-401-fallback.test.ts index 7b0248e3b3..1168355f1c 100644 --- a/packages/opencode/test/kilocode/kilo-models-401-fallback.test.ts +++ b/packages/opencode/test/kilocode/kilo-models-401-fallback.test.ts @@ -25,6 +25,13 @@ mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: () => ({}) })) import { tmpdir } from "../fixture/fixture" import { WithInstance } from "../../src/project/with-instance" import { ModelCache } from "../../src/provider/model-cache" +import { AppRuntime } from "../../src/effect/app-runtime" + +const clear = (id: string) => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.clear(id))) +const fetch = (id: string) => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.fetch(id))) +const failed = () => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.failedProviders())) +const failure = (id: string) => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.getFailure(id))) +const get = (id: string) => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.get(id))) const CONFIG = JSON.stringify({ $schema: "https://app.kilo.ai/config.json" }) @@ -38,16 +45,16 @@ async function withInstance(fn: () => Promise): Promise { } test("401 from gateway sets provider as failed in ModelCache", async () => { - ModelCache.clear("kilo") - await withInstance(() => ModelCache.fetch("kilo")) - expect(ModelCache.failedProviders()).toContain("kilo") - expect(ModelCache.getFailure("kilo")).toMatchObject({ kind: "unauthorized", status: 401 }) + await clear("kilo") + await withInstance(() => fetch("kilo")) + expect(await failed()).toContain("kilo") + expect(await failure("kilo")).toMatchObject({ kind: "unauthorized", status: 401 }) }) test("401 from gateway caches empty models (not undefined)", async () => { - ModelCache.clear("kilo") - await withInstance(() => ModelCache.fetch("kilo")) - const cached = ModelCache.get("kilo") + await clear("kilo") + await withInstance(() => fetch("kilo")) + const cached = await get("kilo") expect(cached).toBeDefined() expect(Object.keys(cached!)).toHaveLength(0) }) diff --git a/packages/opencode/test/kilocode/kilo-sessions.test.ts b/packages/opencode/test/kilocode/kilo-sessions.test.ts new file mode 100644 index 0000000000..fda59f0464 --- /dev/null +++ b/packages/opencode/test/kilocode/kilo-sessions.test.ts @@ -0,0 +1,95 @@ +// kilocode_change - new file +import { expect, spyOn } from "bun:test" +import { Effect, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Auth } from "../../src/auth" +import { Bus } from "../../src/bus" +import type { Config } from "../../src/config/config" +import { KiloSessions } from "../../src/kilo-sessions/kilo-sessions" +import { ProjectID } from "../../src/project/schema" +import { Session } from "../../src/session/session" +import { SessionID } from "../../src/session/schema" +import { TestConfig } from "../fixture/config" +import { testEffect } from "../lib/effect" + +const it = testEffect(CrossSpawnSpawner.defaultLayer) + +function layer(overrides: Partial = {}) { + return Layer.merge( + KiloSessions.layer.pipe(Layer.provideMerge(Bus.layer), Layer.provide(TestConfig.layer(overrides))), + Auth.defaultLayer, + ) +} + +it.instance("initializes once per instance through Config.Service", () => { + let reads = 0 + + return Effect.gen(function* () { + const sessions = yield* KiloSessions.Service + yield* sessions.init() + yield* sessions.init() + expect(reads).toBe(1) + }).pipe( + Effect.provide( + layer({ + getGlobal: () => + Effect.sync(() => { + reads += 1 + return {} + }), + }), + ), + ) +}) + +it.instance("does not duplicate created-session subscribers when init is repeated", () => { + const calls: string[] = [] + const fetch: typeof globalThis.fetch = Object.assign( + async (input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith("/api/user")) return new Response("{}", { status: 200 }) + if (url.endsWith("/api/session")) { + calls.push(url) + return Response.json({ id: "remote-1", ingestPath: "/api/ingest/session-1" }) + } + return new Response("{}", { status: 200 }) + }, + { preconnect: globalThis.fetch.preconnect }, + ) + const request = spyOn(globalThis, "fetch").mockImplementation(fetch) + + const id = SessionID.descending("session-created") + + return Effect.gen(function* () { + const auth = yield* Auth.Service + const bus = yield* Bus.Service + const sessions = yield* KiloSessions.Service + yield* auth.set("kilo", { type: "api", key: "test-token" }) + yield* sessions.init() + yield* sessions.init() + yield* Effect.sleep(50) + yield* bus.publish(Session.Event.Created, { + sessionID: id, + info: { + id, + slug: "test", + projectID: ProjectID.make("project-test"), + directory: "/tmp/test", + title: "test", + version: "test", + time: { created: Date.now(), updated: Date.now() }, + }, + }) + yield* Effect.sleep(50) + expect(calls).toHaveLength(1) + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.remove("kilo").pipe(Effect.orDie) + request.mockRestore() + }), + ), + Effect.provide(layer()), + ) +}) diff --git a/packages/opencode/test/kilocode/model-cache-effect.test.ts b/packages/opencode/test/kilocode/model-cache-effect.test.ts new file mode 100644 index 0000000000..574a30178e --- /dev/null +++ b/packages/opencode/test/kilocode/model-cache-effect.test.ts @@ -0,0 +1,192 @@ +// kilocode_change - new file +import { expect } from "bun:test" +import { Deferred, Effect, Fiber, Layer, Ref } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { Auth } from "../../src/auth" +import { ModelCache } from "../../src/provider/model-cache" +import { TestConfig } from "../fixture/config" +import { testEffect } from "../lib/effect" + +type Hit = { readonly url: string } + +const auth = Layer.mock(Auth.Service)({ + get: () => Effect.succeed(undefined), +}) + +const it = testEffect(Layer.empty) + +function layer( + hits: Ref.Ref, + cfg = TestConfig.layer(), + access = auth, + gates?: { readonly started: Deferred.Deferred; readonly wait: Deferred.Deferred }, +) { + const http = HttpClient.make((request) => + Effect.gen(function* () { + yield* Ref.update(hits, (list) => [...list, { url: request.url }]) + const count = (yield* Ref.get(hits)).length + if (gates && count === 1) { + yield* Deferred.succeed(gates.started, undefined) + yield* Deferred.await(gates.wait) + } + return HttpClientResponse.fromWeb( + request, + Response.json({ data: [{ id: `apertis-${count}`, owned_by: "apertis" }] }), + ) + }), + ) + + return Layer.fresh(ModelCache.layer).pipe( + Layer.provide(Layer.succeed(HttpClient.HttpClient, http)), + Layer.provide(cfg), + Layer.provide(access), + ) +} + +it.live("fetches Apertis models through the injected HttpClient", () => + Effect.gen(function* () { + const hits = yield* Ref.make([]) + const models = yield* ModelCache.Service.use((cache) => + cache.fetch("apertis", { apiKey: "test-key", baseURL: "https://apertis.test/v1" }), + ).pipe(Effect.provide(layer(hits))) + + expect(Object.keys(models)).toEqual(["apertis-1"]) + expect((yield* Ref.get(hits)).map((hit) => hit.url)).toEqual(["https://apertis.test/v1/models"]) + }), +) + +it.live("reuses cached values and refresh invalidates the provider cell", () => + Effect.gen(function* () { + const hits = yield* Ref.make([]) + const run = ModelCache.Service.use((cache) => + Effect.gen(function* () { + const first = yield* cache.fetch("apertis", { apiKey: "test-key" }) + const cached = yield* cache.fetch("apertis", { apiKey: "test-key" }) + const refreshed = yield* cache.refresh("apertis", { apiKey: "test-key" }) + return { first, cached, refreshed } + }), + ).pipe(Effect.provide(layer(hits))) + const out = yield* run + + expect(Object.keys(out.first)).toEqual(["apertis-1"]) + expect(Object.keys(out.cached)).toEqual(["apertis-1"]) + expect(Object.keys(out.refreshed)).toEqual(["apertis-2"]) + expect((yield* Ref.get(hits)).length).toBe(2) + }), +) + +it.live("keeps concurrent request options isolated", () => + Effect.gen(function* () { + const hits = yield* Ref.make([]) + const started = yield* Deferred.make() + const wait = yield* Deferred.make() + const out = yield* ModelCache.Service.use((cache) => + Effect.gen(function* () { + const first = yield* cache + .fetch("apertis", { apiKey: "first", baseURL: "https://first.test/v1" }) + .pipe(Effect.forkChild) + yield* Deferred.await(started) + const second = yield* cache + .fetch("apertis", { apiKey: "second", baseURL: "https://second.test/v1" }) + .pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + yield* Deferred.succeed(wait, undefined) + const firstModels = yield* Fiber.join(first) + const secondModels = yield* Fiber.join(second) + return { first: firstModels, second: secondModels, current: yield* cache.get("apertis") } + }), + ).pipe(Effect.provide(layer(hits, TestConfig.layer(), auth, { started, wait }))) + + expect(Object.keys(out.first)).toEqual(["apertis-1"]) + expect(Object.keys(out.second)).toEqual(["apertis-2"]) + expect(out.current).toEqual(out.second) + expect((yield* Ref.get(hits)).map((hit) => hit.url)).toEqual([ + "https://first.test/v1/models", + "https://second.test/v1/models", + ]) + }), +) + +it.live("does not let an older fetch override a newer refresh", () => + Effect.gen(function* () { + const hits = yield* Ref.make([]) + const started = yield* Deferred.make() + const wait = yield* Deferred.make() + const models = yield* ModelCache.Service.use((cache) => + Effect.gen(function* () { + const stale = yield* cache + .fetch("apertis", { apiKey: "first", baseURL: "https://first.test/v1" }) + .pipe(Effect.forkChild) + yield* Deferred.await(started) + const fresh = yield* cache.refresh("apertis", { apiKey: "second", baseURL: "https://second.test/v1" }) + yield* Deferred.succeed(wait, undefined) + yield* Fiber.join(stale) + return { fresh, current: yield* cache.get("apertis") } + }), + ).pipe(Effect.provide(layer(hits, TestConfig.layer(), auth, { started, wait }))) + + expect(models.current).toEqual(models.fresh) + expect(Object.keys(models.current ?? {})).toEqual(["apertis-2"]) + }), +) + +it.live("does not restore a fetch that was cleared while pending", () => + Effect.gen(function* () { + const hits = yield* Ref.make([]) + const started = yield* Deferred.make() + const wait = yield* Deferred.make() + const current = yield* ModelCache.Service.use((cache) => + Effect.gen(function* () { + const pending = yield* cache + .fetch("apertis", { apiKey: "first", baseURL: "https://first.test/v1" }) + .pipe(Effect.forkChild) + yield* Deferred.await(started) + yield* cache.clear("apertis") + yield* Deferred.succeed(wait, undefined) + yield* Fiber.join(pending) + return yield* cache.get("apertis") + }), + ).pipe(Effect.provide(layer(hits, TestConfig.layer(), auth, { started, wait }))) + + expect(current).toBeUndefined() + }), +) + +it.live("exposes the most recently refreshed provider value", () => + Effect.gen(function* () { + const hits = yield* Ref.make([]) + const models = yield* ModelCache.Service.use((cache) => + Effect.gen(function* () { + yield* cache.fetch("apertis", { apiKey: "first", baseURL: "https://first.test/v1" }) + const refreshed = yield* cache.refresh("apertis", { apiKey: "second", baseURL: "https://second.test/v1" }) + const current = yield* cache.get("apertis") + return { refreshed, current } + }), + ).pipe(Effect.provide(layer(hits))) + + expect(models.current).toEqual(models.refreshed) + expect(Object.keys(models.current ?? {})).toEqual(["apertis-2"]) + }), +) + +it.live("does not resolve auth or config for unsupported providers", () => + Effect.gen(function* () { + const hits = yield* Ref.make([]) + const configs = yield* Ref.make(0) + const auths = yield* Ref.make(0) + const cfg = TestConfig.layer({ + get: () => Ref.update(configs, (count) => count + 1).pipe(Effect.as({})), + }) + const access = Layer.mock(Auth.Service)({ + get: () => Ref.update(auths, (count) => count + 1).pipe(Effect.as(undefined)), + }) + const models = yield* ModelCache.Service.use((cache) => cache.fetch("openai")).pipe( + Effect.provide(layer(hits, cfg, access)), + ) + + expect(models).toEqual({}) + expect(yield* Ref.get(configs)).toBe(0) + expect(yield* Ref.get(auths)).toBe(0) + expect(yield* Ref.get(hits)).toEqual([]) + }), +) diff --git a/packages/opencode/test/kilocode/model-cache-org.test.ts b/packages/opencode/test/kilocode/model-cache-org.test.ts index 29d6353d44..b1d747475c 100644 --- a/packages/opencode/test/kilocode/model-cache-org.test.ts +++ b/packages/opencode/test/kilocode/model-cache-org.test.ts @@ -40,6 +40,11 @@ import { tmpdir } from "../fixture/fixture" import { WithInstance } from "../../src/project/with-instance" import { Filesystem } from "../../src/util/filesystem" import { ModelCache } from "../../src/provider/model-cache" +import { AppRuntime } from "../../src/effect/app-runtime" + +const clear = (id: string) => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.clear(id))) +const fetch = (id: string) => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.fetch(id))) +const get = (id: string) => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.get(id))) const authPath = path.join(Global.Path.data, "auth.json") @@ -75,10 +80,10 @@ test("model fetch uses accountId from OAuth auth as kilocodeOrganizationId", asy fn: async () => { // Reset captured and cache captured = undefined - ModelCache.clear("kilo") + await clear("kilo") // Trigger model fetch through the cache - await ModelCache.fetch("kilo") + await fetch("kilo") // The fetchKiloModels call should have received the organization ID expect(captured).toBeDefined() @@ -126,9 +131,9 @@ test("model fetch without OAuth accountId does not set kilocodeOrganizationId", directory: tmp.path, fn: async () => { captured = undefined - ModelCache.clear("kilo") + await clear("kilo") - await ModelCache.fetch("kilo") + await fetch("kilo") expect(captured).toBeDefined() expect(captured.kilocodeToken).toBe("test-personal-token") @@ -176,25 +181,25 @@ test("ModelCache.clear removes cached entry so next fetch hits the network", asy fn: async () => { // Populate cache captured = undefined - ModelCache.clear("kilo") - await ModelCache.fetch("kilo") + await clear("kilo") + await fetch("kilo") expect(captured).toBeDefined() // Verify cache is populated — second fetch should NOT call fetchKiloModels captured = undefined - await ModelCache.fetch("kilo") + await fetch("kilo") expect(captured).toBeUndefined() - expect(ModelCache.get("kilo")).toBeDefined() + expect(await get("kilo")).toBeDefined() // Clear the cache - ModelCache.clear("kilo") + await clear("kilo") // get() should return undefined after clear - expect(ModelCache.get("kilo")).toBeUndefined() + expect(await get("kilo")).toBeUndefined() // Next fetch should call fetchKiloModels again captured = undefined - await ModelCache.fetch("kilo") + await fetch("kilo") expect(captured).toBeDefined() }, }) diff --git a/packages/opencode/test/kilocode/permission/env-read.test.ts b/packages/opencode/test/kilocode/permission/env-read.test.ts index 22dd538950..6eb67e77bc 100644 --- a/packages/opencode/test/kilocode/permission/env-read.test.ts +++ b/packages/opencode/test/kilocode/permission/env-read.test.ts @@ -14,7 +14,11 @@ import { provideTmpdirInstance } from "../../fixture/fixture" import { testEffect } from "../../lib/effect" const bus = Bus.layer -const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, CrossSpawnSpawner.defaultLayer) +const env = Layer.mergeAll( + Permission.layer.pipe(Layer.provide(bus), Layer.provide(Config.defaultLayer)), + bus, + CrossSpawnSpawner.defaultLayer, +) const it = testEffect(env) afterAll(async () => { @@ -22,7 +26,9 @@ afterAll(async () => { for (const file of ["kilo.jsonc", "kilo.json", "config.json", "opencode.json", "opencode.jsonc"]) { await fs.rm(path.join(dir, file), { force: true }).catch(() => {}) } - await Config.invalidate() + await Effect.runPromise( + Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(Config.defaultLayer)), + ) await InstanceRuntime.disposeAllInstances() }) diff --git a/packages/opencode/test/kilocode/permission/next.always-rules.test.ts b/packages/opencode/test/kilocode/permission/next.always-rules.test.ts index 576a740110..b800dc1760 100644 --- a/packages/opencode/test/kilocode/permission/next.always-rules.test.ts +++ b/packages/opencode/test/kilocode/permission/next.always-rules.test.ts @@ -14,7 +14,12 @@ import { provideTmpdirInstance } from "../../fixture/fixture" import { testEffect } from "../../lib/effect" const bus = Bus.layer -const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, CrossSpawnSpawner.defaultLayer) +const env = Layer.mergeAll( + Permission.layer.pipe(Layer.provide(bus), Layer.provide(Config.defaultLayer)), + Config.defaultLayer, + bus, + CrossSpawnSpawner.defaultLayer, +) const it = testEffect(env) afterAll(async () => { @@ -22,7 +27,9 @@ afterAll(async () => { for (const file of ["kilo.jsonc", "kilo.json", "config.json", "opencode.json", "opencode.jsonc"]) { await fs.rm(path.join(dir, file), { force: true }).catch(() => {}) } - await Config.invalidate() + await Effect.runPromise( + Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(Config.defaultLayer)), + ) await InstanceRuntime.disposeAllInstances() }) @@ -735,7 +742,8 @@ describe("saveAlwaysRules", () => { yield* reply({ requestID: PermissionID.make("permission_saved_always"), reply: "always" }) yield* Fiber.join(fiber) - const cfg = yield* Effect.promise(() => Config.get()) + const config = yield* Config.Service + const cfg = yield* config.get() expect(cfg.permission?.bash).toMatchObject({ "kilo-permission-8353 test": "allow" }) expect(cfg.permission?.bash).not.toMatchObject({ "kilo-permission-8353 *": "allow" }) diff --git a/packages/opencode/test/kilocode/permission/next.reply-routing.test.ts b/packages/opencode/test/kilocode/permission/next.reply-routing.test.ts index 2cd78d4881..af1f256818 100644 --- a/packages/opencode/test/kilocode/permission/next.reply-routing.test.ts +++ b/packages/opencode/test/kilocode/permission/next.reply-routing.test.ts @@ -14,7 +14,11 @@ import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../../fixt import { testEffect } from "../../lib/effect" const bus = Bus.layer -const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, CrossSpawnSpawner.defaultLayer) +const env = Layer.mergeAll( + Permission.layer.pipe(Layer.provide(bus), Layer.provide(Config.defaultLayer)), + bus, + CrossSpawnSpawner.defaultLayer, +) const it = testEffect(env) afterAll(async () => { @@ -22,7 +26,9 @@ afterAll(async () => { for (const file of ["kilo.jsonc", "kilo.json", "config.json", "opencode.json", "opencode.jsonc"]) { await fs.rm(path.join(dir, file), { force: true }).catch(() => {}) } - await Config.invalidate() + await Effect.runPromise( + Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(Config.defaultLayer)), + ) await InstanceRuntime.disposeAllInstances() }) diff --git a/packages/opencode/test/kilocode/provider-list-failed-state.test.ts b/packages/opencode/test/kilocode/provider-list-failed-state.test.ts index 0e424ee8ca..c51738a226 100644 --- a/packages/opencode/test/kilocode/provider-list-failed-state.test.ts +++ b/packages/opencode/test/kilocode/provider-list-failed-state.test.ts @@ -4,7 +4,8 @@ // 2. ModelCache.getFailure() returns the typed error for a failed provider. // 3. Clear removes failure state. -import { test, expect, mock } from "bun:test" +import { beforeEach, test, expect, mock } from "bun:test" +import { Effect } from "effect" import path from "path" import * as Log from "@opencode-ai/core/util/log" @@ -12,9 +13,13 @@ Log.init({ print: false }) // Stub fetchKiloModels to return controlled typed results. let stubbedResult: { models: Record; error?: { kind: string; status?: number } } = { models: {} } +let stubbedError: Error | undefined mock.module("@kilocode/kilo-gateway", () => ({ - fetchKiloModels: async () => stubbedResult, + fetchKiloModels: async () => { + if (stubbedError) throw stubbedError + return stubbedResult + }, KILO_OPENROUTER_BASE: "https://api.kilo.ai/api/openrouter", })) @@ -25,6 +30,12 @@ mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: () => ({}) })) import { tmpdir } from "../fixture/fixture" import { WithInstance } from "../../src/project/with-instance" import { ModelCache } from "../../src/provider/model-cache" +import { AppRuntime } from "../../src/effect/app-runtime" + +const clear = (id: string) => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.clear(id))) +const fetch = (id: string) => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.fetch(id))) +const failed = () => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.failedProviders())) +const failure = (id: string) => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.getFailure(id))) const CONFIG = JSON.stringify({ $schema: "https://app.kilo.ai/config.json" }) @@ -37,9 +48,13 @@ async function withInstance(fn: () => Promise): Promise { return WithInstance.provide({ directory: tmp.path, fn }) } -test("failedProviders returns empty array when no fetch has occurred", () => { - ModelCache.clear("kilo") - expect(ModelCache.failedProviders()).not.toContain("kilo") +beforeEach(() => { + stubbedError = undefined +}) + +test("failedProviders returns empty array when no fetch has occurred", async () => { + await clear("kilo") + expect(await failed()).not.toContain("kilo") }) test("getFailure returns undefined when fetch succeeds", async () => { @@ -53,36 +68,47 @@ test("getFailure returns undefined when fetch succeeds", async () => { }, }, } - ModelCache.clear("kilo") - await withInstance(() => ModelCache.fetch("kilo")) - expect(ModelCache.getFailure("kilo")).toBeUndefined() - expect(ModelCache.failedProviders()).not.toContain("kilo") + await clear("kilo") + await withInstance(() => fetch("kilo")) + expect(await failure("kilo")).toBeUndefined() + expect(await failed()).not.toContain("kilo") }) test("failedProviders includes provider after auth error", async () => { stubbedResult = { models: {}, error: { kind: "unauthorized", status: 401 } } - ModelCache.clear("kilo") - await withInstance(() => ModelCache.fetch("kilo")) - expect(ModelCache.failedProviders()).toContain("kilo") - expect(ModelCache.getFailure("kilo")).toMatchObject({ kind: "unauthorized", status: 401 }) + await clear("kilo") + await withInstance(() => fetch("kilo")) + expect(await failed()).toContain("kilo") + expect(await failure("kilo")).toMatchObject({ kind: "unauthorized", status: 401 }) +}) + +test("gateway rejection remains recoverable through the Effect error channel", async () => { + stubbedError = new Error("gateway failed") + await clear("kilo") + const models = await withInstance(() => + AppRuntime.runPromise( + ModelCache.Service.use((cache) => cache.fetch("kilo").pipe(Effect.catch(() => Effect.succeed({})))), + ), + ) + expect(models).toEqual({}) }) test("clear removes failure state", async () => { stubbedResult = { models: {}, error: { kind: "network" } } - ModelCache.clear("kilo") - await withInstance(() => ModelCache.fetch("kilo")) - expect(ModelCache.failedProviders()).toContain("kilo") + await clear("kilo") + await withInstance(() => fetch("kilo")) + expect(await failed()).toContain("kilo") - ModelCache.clear("kilo") - expect(ModelCache.failedProviders()).not.toContain("kilo") - expect(ModelCache.getFailure("kilo")).toBeUndefined() + await clear("kilo") + expect(await failed()).not.toContain("kilo") + expect(await failure("kilo")).toBeUndefined() }) test("failure state is cleared when subsequent fetch succeeds", async () => { stubbedResult = { models: {}, error: { kind: "unauthorized", status: 401 } } - ModelCache.clear("kilo") - await withInstance(() => ModelCache.fetch("kilo")) - expect(ModelCache.failedProviders()).toContain("kilo") + await clear("kilo") + await withInstance(() => fetch("kilo")) + expect(await failed()).toContain("kilo") stubbedResult = { models: { @@ -94,8 +120,8 @@ test("failure state is cleared when subsequent fetch succeeds", async () => { }, }, } - ModelCache.clear("kilo") - await withInstance(() => ModelCache.fetch("kilo")) - expect(ModelCache.failedProviders()).not.toContain("kilo") - expect(ModelCache.getFailure("kilo")).toBeUndefined() + await clear("kilo") + await withInstance(() => fetch("kilo")) + expect(await failed()).not.toContain("kilo") + expect(await failure("kilo")).toBeUndefined() }) diff --git a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts index 598b46adb6..bf812a2b5e 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts @@ -227,19 +227,27 @@ describe("kilocode tool registry indexing", () => { test("logs indexing bootstrap failures without blocking session bootstrap", async () => { const logger = Log.create({ service: "kilocode-bootstrap" }) const err = new Error("indexing init failed") - const sessions = spyOn(KiloSessions, "init").mockResolvedValue(undefined) + const calls: string[] = [] + const sessions = Layer.succeed( + KiloSessions.Service, + KiloSessions.Service.of({ init: () => Effect.sync(() => calls.push("sessions")) }), + ) const indexing = spyOn(KiloIndexing, "init").mockRejectedValue(err) const warn = spyOn(logger, "warn").mockImplementation(() => {}) try { - await KilocodeBootstrap.init() + await Effect.runPromise( + KilocodeBootstrap.Service.use((svc) => svc.init()).pipe( + Effect.provide(KilocodeBootstrap.layer.pipe(Layer.provide(sessions))), + Effect.scoped, + ), + ) await new Promise((resolve) => setTimeout(resolve, 0)) - expect(sessions).toHaveBeenCalledTimes(1) + expect(calls).toEqual(["sessions"]) expect(indexing).toHaveBeenCalledTimes(1) expect(warn).toHaveBeenCalledWith("indexing bootstrap failed", { err }) } finally { - sessions.mockRestore() indexing.mockRestore() warn.mockRestore() } diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index e2ab583e2f..9276ffad1a 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -23,7 +23,13 @@ import { testEffect } from "../lib/effect" import { MessageID, SessionID } from "../../src/session/schema" const bus = Bus.layer -const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, CrossSpawnSpawner.defaultLayer) +// kilocode_change start +const env = Layer.mergeAll( + Permission.layer.pipe(Layer.provide(bus), Layer.provide(Config.defaultLayer)), + bus, + CrossSpawnSpawner.defaultLayer, +) +// kilocode_change end const it = testEffect(env) afterEach(async () => { @@ -36,7 +42,9 @@ afterAll(async () => { for (const file of ["kilo.jsonc", "kilo.json", "config.json", "opencode.json", "opencode.jsonc"]) { await fs.rm(path.join(dir, file), { force: true }).catch(() => {}) } - await Config.invalidate() + await Effect.runPromise( + Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(Config.defaultLayer)), + ) await InstanceRuntime.disposeAllInstances() }) // kilocode_change end diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index c77c0ca1c0..761ba8ab7e 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -8,12 +8,14 @@ import { ProviderAuth } from "@/provider/auth" import { ProviderID } from "../../src/provider/schema" import { Plugin } from "@/plugin" import { Auth } from "@/auth" +import { ModelCache } from "@/provider/model-cache" // kilocode_change import { Bus } from "@/bus" import { TestConfig } from "../fixture/config" function layer(directory: string, plugins: string[]) { return ProviderAuth.layer.pipe( Layer.provide(Auth.defaultLayer), + Layer.provide(ModelCache.defaultLayer), // kilocode_change Layer.provide( Plugin.layer.pipe( Layer.provide(Bus.layer), diff --git a/packages/opencode/test/provider/models.test.ts b/packages/opencode/test/provider/models.test.ts index 9369b4d21b..4336a3786d 100644 --- a/packages/opencode/test/provider/models.test.ts +++ b/packages/opencode/test/provider/models.test.ts @@ -5,6 +5,8 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Flag } from "@opencode-ai/core/flag/flag" import { Global } from "@opencode-ai/core/global" import { ModelsDev } from "../../src/provider/models" +import { ModelCache } from "../../src/provider/model-cache" // kilocode_change +import { Config } from "../../src/config/config" // kilocode_change import { Auth } from "../../src/auth" // kilocode_change import { it } from "../lib/effect" import { rm, writeFile, utimes, mkdir } from "fs/promises" @@ -90,7 +92,9 @@ const buildLayer = (state: Ref.Ref) => Layer.fresh(ModelsDev.layer).pipe( Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))), Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Config.defaultLayer), // kilocode_change Layer.provide(Auth.defaultLayer), // kilocode_change + Layer.provide(ModelCache.defaultLayer), // kilocode_change ) const writeCache = (data: object, mtimeMs?: number) => @@ -120,10 +124,10 @@ const initialState: MockState = { calls: [], } -// kilocode_change - skip: upstream tests assert raw-fixture passthrough but Kilo's -// ModelsDev.get() filters/injects providers based on Config.get() (kilo-allowed gating, -// apertis options, kilo provider injection). The test setup doesn't provide an Instance -// context, so Config.get() throws "No context found for instance". +// kilocode_change start - skip: upstream tests assert raw-fixture passthrough but Kilo's +// ModelsDev.get() filters/injects providers based on effect-native config access (kilo-allowed +// gating, apertis options, and Kilo provider injection). +// kilocode_change end describe.skip("ModelsDev Service", () => { it.live("get() returns providers from disk when cache file exists", () => Effect.gen(function* () { diff --git a/packages/opencode/test/server/experimental-session-list.test.ts b/packages/opencode/test/server/experimental-session-list.test.ts index 671961198b..ed355eb2df 100644 --- a/packages/opencode/test/server/experimental-session-list.test.ts +++ b/packages/opencode/test/server/experimental-session-list.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" import { $ } from "bun" import path from "path" -import * as Config from "../../src/config/config" import { WithInstance } from "../../src/project/with-instance" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" @@ -29,10 +28,6 @@ describe("experimental.session.list", () => { try { await $`git worktree add ${worktree} -b test-branch-${Date.now()}`.cwd(first.path).quiet() - spyOn(Config, "get").mockImplementation( - async () => ({ share: "manual" }) as Awaited>, - ) - try { const { Server } = await import("../../src/server/server") const { Session } = await import("../../src/session/session") @@ -99,10 +94,6 @@ describe("experimental.session.list", () => { try { await $`git worktree add ${worktree} -b test-branch-sdk-${Date.now()}`.cwd(first.path).quiet() - spyOn(Config, "get").mockImplementation( - async () => ({ share: "manual" }) as Awaited>, - ) - try { const { Server } = await import("../../src/server/server") const { Session } = await import("../../src/session/session") diff --git a/packages/opencode/test/tool/recall.test.ts b/packages/opencode/test/tool/recall.test.ts index a01cdb2917..aa6ef3f64d 100644 --- a/packages/opencode/test/tool/recall.test.ts +++ b/packages/opencode/test/tool/recall.test.ts @@ -4,7 +4,6 @@ import { $ } from "bun" import { Effect } from "effect" import path from "path" import { WithInstance } from "../../src/project/with-instance" -import * as Config from "../../src/config/config" import { RecallTool } from "../../src/tool/recall" import { AppRuntime } from "../../src/effect/app-runtime" import { resetDatabase } from "../fixture/db" @@ -43,10 +42,6 @@ describe("tool.recall", () => { await $`git worktree add ${worktree} -b test-branch-${Date.now()}`.cwd(first.path).quiet() await Bun.write(path.join(first.path, ".git", "opencode"), "stale-project-id") - spyOn(Config, "get").mockImplementation( - async () => ({ share: "manual" }) as Awaited>, - ) - try { const { Session } = await import("../../src/session/session") await WithInstance.provide({ @@ -86,8 +81,6 @@ describe("tool.recall", () => { await using first = await tmpdir({ git: true }) await using second = await tmpdir({ git: true }) - spyOn(Config, "get").mockImplementation(async () => ({ share: "manual" }) as Awaited>) - try { const { Session } = await import("../../src/session/session") const session = await WithInstance.provide({ @@ -121,10 +114,6 @@ describe("tool.recall", () => { await $`git worktree add ${worktree} -b test-branch-${Date.now()}`.cwd(first.path).quiet() await Bun.write(path.join(first.path, ".git", "opencode"), "stale-project-id") - spyOn(Config, "get").mockImplementation( - async () => ({ share: "manual" }) as Awaited>, - ) - try { const { Session } = await import("../../src/session/session") const session = await WithInstance.provide({