mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user