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.
270 lines
8.8 KiB
TypeScript
270 lines
8.8 KiB
TypeScript
import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test"
|
|
import { Effect, Layer, Ref } from "effect"
|
|
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
|
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"
|
|
import path from "path"
|
|
|
|
// test/preload.ts pins KILO_MODELS_PATH to a fixture so other tests can
|
|
// resolve providers without network. These tests need to drive the on-disk
|
|
// cache themselves and silence the eager refresh fork. Save/restore around
|
|
// the suite — never leak the mutation to subsequent test files in the same
|
|
// bun process.
|
|
const ORIGINAL_MODELS_PATH = Flag.KILO_MODELS_PATH
|
|
const ORIGINAL_DISABLE_FETCH = Flag.KILO_DISABLE_MODELS_FETCH
|
|
beforeAll(() => {
|
|
Flag.KILO_MODELS_PATH = undefined
|
|
Flag.KILO_DISABLE_MODELS_FETCH = true
|
|
})
|
|
afterAll(() => {
|
|
Flag.KILO_MODELS_PATH = ORIGINAL_MODELS_PATH
|
|
Flag.KILO_DISABLE_MODELS_FETCH = ORIGINAL_DISABLE_FETCH
|
|
})
|
|
|
|
const cacheFile = path.join(Global.Path.cache, "models.json")
|
|
|
|
const fixture: Record<string, ModelsDev.Provider> = {
|
|
acme: {
|
|
id: "acme",
|
|
name: "Acme",
|
|
env: ["ACME_API_KEY"],
|
|
models: {
|
|
"acme-1": {
|
|
id: "acme-1",
|
|
name: "Acme One",
|
|
release_date: "2026-01-01",
|
|
attachment: false,
|
|
reasoning: false,
|
|
temperature: true,
|
|
tool_call: true,
|
|
limit: { context: 128000, output: 8192 },
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
const fixture2: Record<string, ModelsDev.Provider> = {
|
|
beta: {
|
|
id: "beta",
|
|
name: "Beta",
|
|
env: ["BETA_API_KEY"],
|
|
models: {
|
|
"beta-1": {
|
|
id: "beta-1",
|
|
name: "Beta One",
|
|
release_date: "2026-02-01",
|
|
attachment: false,
|
|
reasoning: true,
|
|
temperature: false,
|
|
tool_call: false,
|
|
limit: { context: 64000, output: 4096 },
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
interface MockState {
|
|
body: string
|
|
status: number
|
|
calls: Array<{ url: string }>
|
|
}
|
|
|
|
const makeMockClient = (state: Ref.Ref<MockState>) =>
|
|
HttpClient.make((request) =>
|
|
Effect.gen(function* () {
|
|
yield* Ref.update(state, (s) => ({ ...s, calls: [...s.calls, { url: request.url }] }))
|
|
const s = yield* Ref.get(state)
|
|
return HttpClientResponse.fromWeb(request, new Response(s.body, { status: s.status }))
|
|
}),
|
|
)
|
|
|
|
const buildLayer = (state: Ref.Ref<MockState>) =>
|
|
// Layer.fresh is required: ModelsDev.layer is a module-level Layer constant,
|
|
// and Effect.provide uses a process-global MemoMap by default — without fresh,
|
|
// every test would reuse the cachedInvalidateWithTTL state from the first run.
|
|
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) =>
|
|
Effect.promise(async () => {
|
|
await mkdir(Global.Path.cache, { recursive: true })
|
|
await writeFile(cacheFile, JSON.stringify(data))
|
|
if (mtimeMs !== undefined) {
|
|
const t = mtimeMs / 1000
|
|
await utimes(cacheFile, t, t)
|
|
}
|
|
})
|
|
|
|
const provided = <A, E>(state: Ref.Ref<MockState>, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
|
|
eff.pipe(Effect.provide(buildLayer(state)))
|
|
|
|
beforeEach(async () => {
|
|
await rm(cacheFile, { force: true })
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await rm(cacheFile, { force: true })
|
|
})
|
|
|
|
const initialState: MockState = {
|
|
body: JSON.stringify(fixture),
|
|
status: 200,
|
|
calls: [],
|
|
}
|
|
|
|
// 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* () {
|
|
yield* writeCache(fixture)
|
|
const state = yield* Ref.make(initialState)
|
|
const result = yield* provided(
|
|
state,
|
|
ModelsDev.Service.use((s) => s.get()),
|
|
)
|
|
expect(result).toEqual(fixture)
|
|
const final = yield* Ref.get(state)
|
|
expect(final.calls).toEqual([])
|
|
}),
|
|
)
|
|
|
|
it.live("get() returns {} when disk empty and fetch disabled", () =>
|
|
Effect.gen(function* () {
|
|
const state = yield* Ref.make(initialState)
|
|
const result = yield* provided(
|
|
state,
|
|
ModelsDev.Service.use((s) => s.get()),
|
|
)
|
|
expect(result).toEqual({})
|
|
const final = yield* Ref.get(state)
|
|
expect(final.calls).toEqual([])
|
|
}),
|
|
)
|
|
|
|
it.live("get() is single-flight under concurrent calls", () =>
|
|
Effect.gen(function* () {
|
|
yield* writeCache(fixture)
|
|
const state = yield* Ref.make(initialState)
|
|
const results = yield* provided(
|
|
state,
|
|
Effect.gen(function* () {
|
|
const svc = yield* ModelsDev.Service
|
|
return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], {
|
|
concurrency: "unbounded",
|
|
})
|
|
}),
|
|
)
|
|
for (const result of results) expect(result).toEqual(fixture)
|
|
}),
|
|
)
|
|
|
|
it.live("get() caches across calls (later disk writes are ignored until invalidate)", () =>
|
|
Effect.gen(function* () {
|
|
yield* writeCache(fixture)
|
|
const state = yield* Ref.make(initialState)
|
|
const first = yield* provided(
|
|
state,
|
|
Effect.gen(function* () {
|
|
const svc = yield* ModelsDev.Service
|
|
const a = yield* svc.get()
|
|
// mutate disk between calls — cache should mask the change
|
|
yield* writeCache(fixture2)
|
|
const b = yield* svc.get()
|
|
return { a, b }
|
|
}),
|
|
)
|
|
expect(first.a).toEqual(fixture)
|
|
expect(first.b).toEqual(fixture)
|
|
}),
|
|
)
|
|
|
|
it.live("refresh(true) fetches via HttpClient and updates the cache", () =>
|
|
Effect.gen(function* () {
|
|
yield* writeCache(fixture)
|
|
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
|
const result = yield* provided(
|
|
state,
|
|
Effect.gen(function* () {
|
|
const svc = yield* ModelsDev.Service
|
|
const before = yield* svc.get()
|
|
yield* svc.refresh(true)
|
|
const after = yield* svc.get()
|
|
return { before, after }
|
|
}),
|
|
)
|
|
expect(result.before).toEqual(fixture)
|
|
expect(result.after).toEqual(fixture2)
|
|
const final = yield* Ref.get(state)
|
|
expect(final.calls.length).toBe(1)
|
|
expect(final.calls[0].url).toContain("/api.json")
|
|
}),
|
|
)
|
|
|
|
it.live("refresh(false) skips fetch when on-disk file is fresh", () =>
|
|
Effect.gen(function* () {
|
|
// Fresh: mtime within the 5-minute TTL.
|
|
yield* writeCache(fixture, Date.now() - 1000)
|
|
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
|
yield* provided(
|
|
state,
|
|
ModelsDev.Service.use((s) => s.refresh(false)),
|
|
)
|
|
const final = yield* Ref.get(state)
|
|
expect(final.calls).toEqual([])
|
|
}),
|
|
)
|
|
|
|
it.live("refresh(false) fetches when on-disk file is stale", () =>
|
|
Effect.gen(function* () {
|
|
// Stale: mtime 10 minutes ago, beyond the 5-minute TTL.
|
|
yield* writeCache(fixture, Date.now() - 10 * 60 * 1000)
|
|
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
|
const after = yield* provided(
|
|
state,
|
|
Effect.gen(function* () {
|
|
const svc = yield* ModelsDev.Service
|
|
yield* svc.refresh(false)
|
|
return yield* svc.get()
|
|
}),
|
|
)
|
|
const final = yield* Ref.get(state)
|
|
expect(final.calls.length).toBe(1)
|
|
expect(after).toEqual(fixture2)
|
|
}),
|
|
)
|
|
|
|
it.live("refresh swallows HTTP errors and leaves cache intact", () =>
|
|
Effect.gen(function* () {
|
|
yield* writeCache(fixture)
|
|
const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" })
|
|
const result = yield* provided(
|
|
state,
|
|
Effect.gen(function* () {
|
|
const svc = yield* ModelsDev.Service
|
|
yield* svc.refresh(true)
|
|
return yield* svc.get()
|
|
}),
|
|
)
|
|
expect(result).toEqual(fixture)
|
|
// withTransientReadRetry retries 5xx, so calls may be > 1.
|
|
const final = yield* Ref.get(state)
|
|
expect(final.calls.length).toBeGreaterThanOrEqual(1)
|
|
}),
|
|
)
|
|
})
|