39a7305c97
* 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.
193 lines
7.3 KiB
TypeScript
193 lines
7.3 KiB
TypeScript
// 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([])
|
|
}),
|
|
)
|