Effect Migration for Kilo callsites (follow-up) (#10587)

* refactor(opencode): migrate ModelCache and Config to effect-native services

Remove legacy async wrapper functions from Config module and convert
ModelCache from a stateful namespace with module-level Maps into a
proper Effect service with Context/Layer semantics.

Key changes:
- Delete Config's `makeRuntime`-based async wrappers (get, getGlobal,
  update, warnings, etc.) — all callsites now use
  `Config.Service.use(...)` through AppRuntime
- Rewrite ModelCache as an Effect service with HttpClient dependency
  injection, replacing imperative Map-based caching with Effect-native
  Ref cells and TTL logic
- Convert KiloSessions.init and KilocodeBootstrap.init into proper
  Effect services with Layer-based dependency injection
- Wire ModelCache.Service into AppLayer, ProviderAuth, ModelsDev, and
  HTTP API handler layers
- Update Permission.layer to depend on Config.Service directly instead
  of calling Config async wrappers
- Add new test files for KiloSessions and ModelCache Effect integration
- Remove stale Config.get spyOn mocks from tests that no longer need
  them (experimental-session-list, recall)
- Fix indexing-auth to use typed IndexingConfig parameter instead of
  untyped record access

* fix(model-cache): resolve race conditions in concurrent fetch and cache invalidation

Introduce versioned cache cells with proper key derivation to prevent
stale responses from overwriting fresher data during concurrent fetches.

- Add version tracking to detect and discard outdated fetch results
- Derive cache keys from provider-specific options (baseURL, token, apiKey)
  to isolate concurrent requests with different credentials
- Make ModelCache.clear async to properly await invalidation across layers
- Update OrganizationDeps.clear signature to allow Promise<void> return
- Add concurrency and ordering tests for fetch/refresh race scenarios
- Rename local variable from `state` to `entry` in kilo-sessions sync loop

* chore(opencode): remove duplicate imports and fix test layer composition

Remove duplicate `AppRuntime` imports introduced during merge and update
kilo-sessions tests to use Effect-native Auth service instead of static
module calls.

- Remove duplicate `AppRuntime` import in index.ts and instance.ts
- Add Auth.defaultLayer to test layer helper
- Refactor test to yield Auth.Service and use instance methods
- Reorder Effect.provide/Effect.ensuring for correct resource cleanup

* style(opencode): normalize kilocode_change marker comments to block format

Standardize inline `// kilocode_change` annotations across source and
test files to use consistent `// kilocode_change start` / `// kilocode_change end`
block delimiters, improving readability and grep-ability of custom
modifications.
This commit is contained in:
Imanol Maiztegui
2026-05-27 11:43:32 +02:00
committed by GitHub
parent cb933859ff
commit 39a7305c97
43 changed files with 977 additions and 664 deletions
+20 -8
View File
@@ -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()
@@ -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([])
},
@@ -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")
@@ -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<ReturnType<typeof Provider.list>>) {
const item = providers[ProviderID.kilo]
@@ -51,13 +54,12 @@ function paid(providers: Awaited<ReturnType<typeof Provider.list>>) {
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) => {
@@ -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<T>(fn: () => Promise<T>): Promise<T> {
}
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)
})
@@ -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<Config.Interface> = {}) {
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()),
)
})
@@ -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<Hit[]>,
cfg = TestConfig.layer(),
access = auth,
gates?: { readonly started: Deferred.Deferred<void>; readonly wait: Deferred.Deferred<void> },
) {
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<Hit[]>([])
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<Hit[]>([])
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<Hit[]>([])
const started = yield* Deferred.make<void>()
const wait = yield* Deferred.make<void>()
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<Hit[]>([])
const started = yield* Deferred.make<void>()
const wait = yield* Deferred.make<void>()
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<Hit[]>([])
const started = yield* Deferred.make<void>()
const wait = yield* Deferred.make<void>()
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<Hit[]>([])
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<Hit[]>([])
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([])
}),
)
@@ -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()
},
})
@@ -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()
})
@@ -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" })
@@ -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()
})
@@ -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<string, any>; 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<T>(fn: () => Promise<T>): Promise<T> {
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()
})
@@ -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()
}
+10 -2
View File
@@ -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
@@ -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),
@@ -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<MockState>) =>
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* () {
@@ -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<ReturnType<typeof Config.get>>,
)
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<ReturnType<typeof Config.get>>,
)
try {
const { Server } = await import("../../src/server/server")
const { Session } = await import("../../src/session/session")
@@ -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<ReturnType<typeof Config.get>>,
)
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<ReturnType<typeof Config.get>>)
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<ReturnType<typeof Config.get>>,
)
try {
const { Session } = await import("../../src/session/session")
const session = await WithInstance.provide({