From 8bc05fa9f54cff2237421175f424a1d760ee9e7f Mon Sep 17 00:00:00 2001 From: Imanol Maiztegui Date: Wed, 27 May 2026 15:42:15 +0200 Subject: [PATCH] test(opencode): replace mock.module stubs with Effect layer injection in kilo tests (#10615) Extract KiloModelsService from model-cache to enable dependency injection and rewrite test files to use Layer-based test harnesses instead of process-wide mock.module() calls that caused flaky cross-test pollution on CI. --- packages/opencode/src/provider/model-cache.ts | 23 +- .../test/kilocode/kilo-loader-auth.test.ts | 281 ++++++++--------- .../kilocode/kilo-models-401-fallback.test.ts | 93 +++--- .../test/kilocode/model-cache-effect.test.ts | 1 + .../test/kilocode/model-cache-org.test.ts | 290 ++++++------------ .../provider-list-failed-state.test.ts | 232 ++++++++------ 6 files changed, 412 insertions(+), 508 deletions(-) diff --git a/packages/opencode/src/provider/model-cache.ts b/packages/opencode/src/provider/model-cache.ts index 3d452b2d21..750acb91b4 100644 --- a/packages/opencode/src/provider/model-cache.ts +++ b/packages/opencode/src/provider/model-cache.ts @@ -13,6 +13,19 @@ type Options = { -readonly [K in keyof KiloOptions]?: KiloOptions[K] } & { apiKe type Failure = NonNullable type Result = { readonly models: Models; readonly error?: Failure } type View = { models?: Models; timestamp?: number } + +export interface KiloModels { + readonly fetch: (options: KiloOptions) => Effect.Effect +} + +export class KiloModelsService extends Context.Service()( + "@kilocode/ModelCache/KiloModels", +) {} + +export const kiloModelsLayer = Layer.succeed( + KiloModelsService, + KiloModelsService.of({ fetch: (options) => Effect.tryPromise(() => fetchKiloModels(options)) }), +) type Cell = { readonly providerID: string readonly view: View @@ -38,11 +51,16 @@ const ApertisItem = Schema.Struct({ id: Schema.String, owned_by: Schema.optional const ApertisResponse = Schema.Struct({ data: Schema.optional(Schema.Array(ApertisItem)) }) type ApertisItem = Schema.Schema.Type -export const layer: Layer.Layer = Layer.effect( +export const layer: Layer.Layer< + Service, + never, + Auth.Service | Config.Service | KiloModelsService | HttpClient.HttpClient +> = Layer.effect( Service, Effect.gen(function* () { const auth = yield* Auth.Service const cfg = yield* Config.Service + const kilo = yield* KiloModelsService const http = yield* HttpClient.HttpClient const cells = new Map() const active = new Map() @@ -140,7 +158,7 @@ export const layer: Layer.Layer => { - if (providerID === "kilo") return Effect.tryPromise(() => fetchKiloModels(options)) + if (providerID === "kilo") return kilo.fetch(options) if (providerID === "apertis") return fetchApertisModels(options).pipe(Effect.map((models) => ({ models }))) log.debug("provider not implemented", { providerID }) return Effect.succeed({ models: {} }) @@ -261,6 +279,7 @@ export const defaultLayer = layer.pipe( Layer.provide(FetchHttpClient.layer), Layer.provide(Auth.defaultLayer), Layer.provide(Config.defaultLayer), + Layer.provide(kiloModelsLayer), ) export * as ModelCache from "./model-cache" diff --git a/packages/opencode/test/kilocode/kilo-loader-auth.test.ts b/packages/opencode/test/kilocode/kilo-loader-auth.test.ts index cabff0e145..af70454ae1 100644 --- a/packages/opencode/test/kilocode/kilo-loader-auth.test.ts +++ b/packages/opencode/test/kilocode/kilo-loader-auth.test.ts @@ -1,174 +1,139 @@ // kilocode_change - new file -// -// Tests that the kilo custom loader keeps paid models visible without authentication. -// Mocks fetchKiloModels from @kilocode/kilo-gateway to avoid real network -// calls (which fail on Windows CI). +// Tests that unauthenticated Kilo models are assembled with paid models and autoloaded anonymously. -import { test, expect, mock } from "bun:test" -import path from "path" -import { unlink } from "fs/promises" - -// Bun's mock.module() is process-wide and permanent — it replaces the module -// for ALL test files in the same runner process. To avoid breaking other tests -// that import @kilocode/kilo-gateway, we spread the real exports and only -// override fetchKiloModels with a stub that returns both free and paid models. -const real = await import("@kilocode/kilo-gateway") - -mock.module("@kilocode/kilo-gateway", () => ({ - ...real, - fetchKiloModels: async () => ({ - models: { - "free-model": { - id: "free-model", - name: "Free Model", - cost: { input: 0, output: 0 }, - limit: { context: 128000, output: 4096 }, - }, - "paid-model": { - id: "paid-model", - name: "Paid Model", - cost: { input: 1.0, output: 2.0 }, - limit: { context: 128000, output: 4096 }, - }, - }, - }), -})) - -import { tmpdir } from "../fixture/fixture" -import { Global } from "@opencode-ai/core/global" -import { WithInstance } from "../../src/project/with-instance" -import { Provider } from "../../src/provider/provider" -import { ProviderID } from "../../src/provider/schema" -import { Filesystem } from "../../src/util/filesystem" +import { expect } from "bun:test" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Effect, Layer } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import { kiloCustomLoaders } from "../../src/kilocode/provider/provider" +import { Auth } from "../../src/auth" import { ModelCache } from "../../src/provider/model-cache" -import { AppRuntime } from "../../src/effect/app-runtime" +import { ModelsDev } from "../../src/provider/models" +import { Provider } from "../../src/provider/provider" +import { TestConfig } from "../fixture/config" +import { testEffect } from "../lib/effect" -const clear = (id: string) => AppRuntime.runPromise(ModelCache.Service.use((cache) => cache.clear(id))) - -function paid(providers: Awaited>) { - const item = providers[ProviderID.kilo] - expect(item).toBeDefined() - return Object.values(item.models).filter((model) => model.cost.input > 0).length +const input = { + id: "kilo", + env: ["KILO_API_KEY"], + models: { + "free-model": { + id: "free-model", + name: "Free Model", + cost: { input: 0, output: 0 }, + limit: { context: 128000, output: 4096 }, + }, + "paid-model": { + id: "paid-model", + name: "Paid Model", + cost: { input: 1, output: 2 }, + limit: { context: 128000, output: 4096 }, + }, + }, } -const authPath = path.join(Global.Path.data, "auth.json") +const seed: Record = { + apertis: { + id: "apertis", + name: "Apertis", + env: ["APERTIS_API_KEY"], + models: {}, + }, +} -test("kilo loader keeps paid models without auth and when config apiKey is present", async () => { - // Persisted auth from other tests and ModelCache's TTL map must not affect this test. - const prev = await Filesystem.readText(authPath).catch(() => undefined) +const auth = Layer.mock(Auth.Service)({ + get: () => Effect.succeed(undefined), +}) - try { - await Filesystem.write(authPath, JSON.stringify({})) - await clear("kilo") - - await using base = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "kilo.json"), - JSON.stringify({ - $schema: "https://app.kilo.ai/config.json", - }), - ) - }, +const files = Layer.effect( + AppFileSystem.Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + return AppFileSystem.Service.of({ + ...fs, + readJson: () => Effect.succeed(seed), + stat: () => fs.stat(import.meta.path), }) + }), +).pipe(Layer.provide(AppFileSystem.defaultLayer)) - const none = await WithInstance.provide({ - directory: base.path, - fn: async () => paid(await Provider.list()), - }) +function load(data?: { auth?: object; config?: object; env?: Record }) { + return kiloCustomLoaders({ + auth: () => Effect.succeed(data?.auth), + config: () => Effect.succeed(data?.config ?? {}), + env: () => Effect.succeed(data?.env ?? {}), + get: () => Effect.succeed(undefined), + }).kilo(input) +} - await using keyed = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "kilo.json"), - JSON.stringify({ - $schema: "https://app.kilo.ai/config.json", - provider: { - kilo: { - options: { - apiKey: "test-key", - }, - }, +function layer() { + const cfg = TestConfig.layer() + const models = Layer.succeed( + ModelCache.KiloModelsService, + ModelCache.KiloModelsService.of({ + fetch: () => + Effect.succeed({ + models: { + "paid-model": { + id: "paid-model", + name: "Paid Model", + cost: { input: 1, output: 2 }, + limit: { context: 128000, output: 4096 }, }, - }), - ) - }, + }, + }), + }), + ) + const cache = Layer.fresh(ModelCache.layer).pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(cfg), + Layer.provide(auth), + Layer.provide(models), + ) + return Layer.fresh(ModelsDev.layer).pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(files), + Layer.provide(cfg), + Layer.provide(auth), + Layer.provide(cache), + ) +} + +const it = testEffect(Layer.empty) + +it.live("assembles paid Kilo models without auth", () => + Effect.gen(function* () { + const providers = yield* ModelsDev.Service.use((models) => models.get()).pipe(Effect.provide(layer())) + const kilo = Provider.fromModelsDevProvider(providers.kilo) + + expect(kilo.models["paid-model"]).toMatchObject({ + id: "paid-model", + providerID: "kilo", + cost: { input: 1, output: 2 }, }) + }), +) - const count = await WithInstance.provide({ - directory: keyed.path, - fn: async () => paid(await Provider.list()), - }) +it.effect("enables a paid catalog anonymously without auth", () => + Effect.gen(function* () { + const result = yield* load() + expect(result.autoload).toBe(true) + expect(result.options).toEqual({ apiKey: "anonymous" }) + }), +) - expect(none).toBeGreaterThan(0) - expect(count).toBeGreaterThan(0) - } finally { - if (prev !== undefined) { - await Filesystem.write(authPath, prev) - } - if (prev === undefined) { - await unlink(authPath).catch(() => undefined) - } - } -}) +it.effect("enables a paid catalog when config apiKey is present", () => + Effect.gen(function* () { + const result = yield* load({ config: { provider: { kilo: { options: { apiKey: "test-key" } } } } }) + expect(result.autoload).toBe(true) + expect(result.options).toEqual({}) + }), +) -test("kilo loader keeps paid models without auth and when auth exists", async () => { - const prev = await Filesystem.readText(authPath).catch(() => undefined) - - try { - await Filesystem.write(authPath, JSON.stringify({})) - await clear("kilo") - - await using base = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "kilo.json"), - JSON.stringify({ - $schema: "https://app.kilo.ai/config.json", - }), - ) - }, - }) - - const none = await WithInstance.provide({ - directory: base.path, - fn: async () => paid(await Provider.list()), - }) - - await using keyed = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "kilo.json"), - JSON.stringify({ - $schema: "https://app.kilo.ai/config.json", - }), - ) - }, - }) - - await Filesystem.write( - authPath, - JSON.stringify({ - kilo: { - type: "api", - key: "test-key", - }, - }), - ) - - const count = await WithInstance.provide({ - directory: keyed.path, - fn: async () => paid(await Provider.list()), - }) - - expect(none).toBeGreaterThan(0) - expect(count).toBeGreaterThan(0) - } finally { - if (prev !== undefined) { - await Filesystem.write(authPath, prev) - } - if (prev === undefined) { - await unlink(authPath).catch(() => undefined) - } - } -}) +it.effect("enables a paid catalog when auth exists", () => + Effect.gen(function* () { + const result = yield* load({ auth: { type: "api", key: "test-key" } }) + expect(result.autoload).toBe(true) + expect(result.options).toEqual({}) + }), +) diff --git a/packages/opencode/test/kilocode/kilo-models-401-fallback.test.ts b/packages/opencode/test/kilocode/kilo-models-401-fallback.test.ts index 1168355f1c..4f8f15e11d 100644 --- a/packages/opencode/test/kilocode/kilo-models-401-fallback.test.ts +++ b/packages/opencode/test/kilocode/kilo-models-401-fallback.test.ts @@ -1,60 +1,53 @@ // kilocode_change - new file -// Integration: when fetchKiloModels returns a 401 error result, ModelCache -// surfaces the failure and caches empty models (allowing re-auth via /connect). -// The real 401-fallback unit test lives in packages/kilo-gateway/test/api/models.test.ts. +// When the injected Kilo models source returns a 401 error result, ModelCache surfaces +// the failure and caches empty models (allowing re-auth via /connect). +// The real fetchKiloModels 401-fallback unit test lives in packages/kilo-gateway/test/api/models.test.ts. -import { test, expect, mock } from "bun:test" -import path from "path" +import { expect } from "bun:test" +import { Effect, Layer } from "effect" +import { FetchHttpClient } from "effect/unstable/http" import * as Log from "@opencode-ai/core/util/log" Log.init({ print: false }) -// Simulate a 401 typed error result from the gateway -mock.module("@kilocode/kilo-gateway", () => ({ - fetchKiloModels: async () => ({ - models: {}, - error: { kind: "unauthorized", status: 401 }, - }), - KILO_OPENROUTER_BASE: "https://api.kilo.ai/api/openrouter", -})) - -mock.module("opencode-copilot-auth", () => ({ default: () => ({}) })) -mock.module("opencode-anthropic-auth", () => ({ default: () => ({}) })) -mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: () => ({}) })) - -import { tmpdir } from "../fixture/fixture" -import { WithInstance } from "../../src/project/with-instance" +import { Auth } from "../../src/auth" import { ModelCache } from "../../src/provider/model-cache" -import { AppRuntime } from "../../src/effect/app-runtime" +import { TestConfig } from "../fixture/config" +import { testEffect } from "../lib/effect" -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" }) - -async function withInstance(fn: () => Promise): Promise { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "kilo.json"), CONFIG) - }, - }) - return WithInstance.provide({ directory: tmp.path, fn }) -} - -test("401 from gateway sets provider as failed in ModelCache", async () => { - await clear("kilo") - await withInstance(() => fetch("kilo")) - expect(await failed()).toContain("kilo") - expect(await failure("kilo")).toMatchObject({ kind: "unauthorized", status: 401 }) +const auth = Layer.mock(Auth.Service)({ + get: () => Effect.succeed(undefined), }) -test("401 from gateway caches empty models (not undefined)", async () => { - await clear("kilo") - await withInstance(() => fetch("kilo")) - const cached = await get("kilo") - expect(cached).toBeDefined() - expect(Object.keys(cached!)).toHaveLength(0) -}) +const models = Layer.succeed( + ModelCache.KiloModelsService, + ModelCache.KiloModelsService.of({ + fetch: () => Effect.succeed({ models: {}, error: { kind: "unauthorized", status: 401 } }), + }), +) + +const layer = Layer.fresh(ModelCache.layer).pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(TestConfig.layer()), + Layer.provide(auth), + Layer.provide(models), +) + +const it = testEffect(layer) + +it.live("401 from Kilo models sets provider as failed in ModelCache", () => + Effect.gen(function* () { + const cache = yield* ModelCache.Service + yield* cache.fetch("kilo") + expect(yield* cache.failedProviders()).toContain("kilo") + expect(yield* cache.getFailure("kilo")).toMatchObject({ kind: "unauthorized", status: 401 }) + }), +) + +it.live("401 from Kilo models caches empty models (not undefined)", () => + Effect.gen(function* () { + const cache = yield* ModelCache.Service + yield* cache.fetch("kilo") + expect(yield* cache.get("kilo")).toEqual({}) + }), +) diff --git a/packages/opencode/test/kilocode/model-cache-effect.test.ts b/packages/opencode/test/kilocode/model-cache-effect.test.ts index 574a30178e..caa57aca54 100644 --- a/packages/opencode/test/kilocode/model-cache-effect.test.ts +++ b/packages/opencode/test/kilocode/model-cache-effect.test.ts @@ -40,6 +40,7 @@ function layer( Layer.provide(Layer.succeed(HttpClient.HttpClient, http)), Layer.provide(cfg), Layer.provide(access), + Layer.provide(ModelCache.kiloModelsLayer), ) } diff --git a/packages/opencode/test/kilocode/model-cache-org.test.ts b/packages/opencode/test/kilocode/model-cache-org.test.ts index b1d747475c..cc27b6c9fe 100644 --- a/packages/opencode/test/kilocode/model-cache-org.test.ts +++ b/packages/opencode/test/kilocode/model-cache-org.test.ts @@ -2,213 +2,111 @@ // When a user logs in via OAuth and selects an enterprise organization, the model fetch // should use the organization-specific endpoint, not the personal endpoint. -import { test, expect, mock } from "bun:test" -import { unlink } from "fs/promises" -import path from "path" -import { Global } from "@opencode-ai/core/global" +import { expect } from "bun:test" +import { Effect, Layer, Ref } from "effect" +import { FetchHttpClient } from "effect/unstable/http" import * as Log from "@opencode-ai/core/util/log" Log.init({ print: false }) -// Capture the options passed to fetchKiloModels -let captured: any = undefined - -mock.module("@kilocode/kilo-gateway", () => ({ - fetchKiloModels: async (options: any) => { - captured = options - return { - models: { - "test-model": { - id: "test-model", - name: "Test Model", - cost: { input: 0.001, output: 0.002 }, - limit: { context: 128000, output: 4096 }, - }, - }, - } - }, - KILO_OPENROUTER_BASE: "https://api.kilo.ai/api/openrouter", -})) - -// Mock default plugins to prevent actual installations during tests -const mockPlugin = () => ({}) -mock.module("opencode-copilot-auth", () => ({ default: mockPlugin })) -mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin })) -mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: mockPlugin })) - -import { tmpdir } from "../fixture/fixture" -import { WithInstance } from "../../src/project/with-instance" -import { Filesystem } from "../../src/util/filesystem" +import { Auth } from "../../src/auth" import { ModelCache } from "../../src/provider/model-cache" -import { AppRuntime } from "../../src/effect/app-runtime" +import { TestConfig } from "../fixture/config" +import { testEffect } from "../lib/effect" -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))) +type Options = Parameters[0] -const authPath = path.join(Global.Path.data, "auth.json") - -test("model fetch uses accountId from OAuth auth as kilocodeOrganizationId", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://app.kilo.ai/config.json", - }), - ) - }, +function layer(info: Auth.Info | undefined, captured: Ref.Ref) { + const auth = Layer.mock(Auth.Service)({ + get: (id) => Effect.succeed(id === "kilo" ? info : undefined), }) - // Simulate an OAuth login where user selected an enterprise organization - const prev = await Filesystem.readText(authPath).catch(() => undefined) + const models = Layer.succeed( + ModelCache.KiloModelsService, + ModelCache.KiloModelsService.of({ + fetch: (options) => + Ref.set(captured, options).pipe( + Effect.as({ + models: { + "test-model": { + id: "test-model", + name: "Test Model", + cost: { input: 0.001, output: 0.002 }, + limit: { context: 128000, output: 4096 }, + }, + }, + }), + ), + }), + ) + return Layer.fresh(ModelCache.layer).pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(TestConfig.layer()), + Layer.provide(auth), + Layer.provide(models), + ) +} - try { - await Filesystem.write( - authPath, - JSON.stringify({ - kilo: { - type: "oauth", - access: "test-oauth-token", - refresh: "test-refresh-token", - expires: Date.now() + 3600000, - accountId: "org-enterprise-123", - }, - }), - ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - // Reset captured and cache - captured = undefined - await clear("kilo") +const it = testEffect(Layer.empty) - // Trigger model fetch through the cache - await fetch("kilo") - - // The fetchKiloModels call should have received the organization ID - expect(captured).toBeDefined() - expect(captured.kilocodeToken).toBe("test-oauth-token") - expect(captured.kilocodeOrganizationId).toBe("org-enterprise-123") - }, +it.live("model fetch uses accountId from OAuth auth as kilocodeOrganizationId", () => + Effect.gen(function* () { + const captured = yield* Ref.make(undefined) + const info = new Auth.Oauth({ + type: "oauth", + access: "test-oauth-token", + refresh: "test-refresh-token", + expires: Date.now() + 3600000, + accountId: "org-enterprise-123", }) - } finally { - if (prev !== undefined) { - await Filesystem.write(authPath, prev) - } - if (prev === undefined) { - await unlink(authPath).catch(() => undefined) - } - } -}) - -test("model fetch without OAuth accountId does not set kilocodeOrganizationId", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://app.kilo.ai/config.json", - }), - ) - }, - }) - // Simulate an OAuth login for a personal account (no accountId) - const prev = await Filesystem.readText(authPath).catch(() => undefined) - - try { - await Filesystem.write( - authPath, - JSON.stringify({ - kilo: { - type: "oauth", - access: "test-personal-token", - refresh: "test-refresh-token", - expires: Date.now() + 3600000, - }, - }), - ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - captured = undefined - await clear("kilo") - - await fetch("kilo") - - expect(captured).toBeDefined() - expect(captured.kilocodeToken).toBe("test-personal-token") - expect(captured.kilocodeOrganizationId).toBeUndefined() - }, + yield* ModelCache.Service.use((cache) => cache.fetch("kilo")).pipe(Effect.provide(layer(info, captured))) + expect(yield* Ref.get(captured)).toMatchObject({ + kilocodeToken: "test-oauth-token", + kilocodeOrganizationId: "org-enterprise-123", }) - } finally { - if (prev !== undefined) { - await Filesystem.write(authPath, prev) - } - if (prev === undefined) { - await unlink(authPath).catch(() => undefined) - } - } -}) + }), +) -test("ModelCache.clear removes cached entry so next fetch hits the network", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://app.kilo.ai/config.json", - }), - ) - }, - }) - const prev = await Filesystem.readText(authPath).catch(() => undefined) - - try { - await Filesystem.write( - authPath, - JSON.stringify({ - kilo: { - type: "oauth", - access: "token-clear-test", - refresh: "refresh-clear", - expires: Date.now() + 3600000, - accountId: "org-clear", - }, - }), - ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - // Populate cache - captured = undefined - await clear("kilo") - await fetch("kilo") - expect(captured).toBeDefined() - - // Verify cache is populated — second fetch should NOT call fetchKiloModels - captured = undefined - await fetch("kilo") - expect(captured).toBeUndefined() - expect(await get("kilo")).toBeDefined() - - // Clear the cache - await clear("kilo") - - // get() should return undefined after clear - expect(await get("kilo")).toBeUndefined() - - // Next fetch should call fetchKiloModels again - captured = undefined - await fetch("kilo") - expect(captured).toBeDefined() - }, +it.live("model fetch without OAuth accountId does not set kilocodeOrganizationId", () => + Effect.gen(function* () { + const captured = yield* Ref.make(undefined) + const info = new Auth.Oauth({ + type: "oauth", + access: "test-personal-token", + refresh: "test-refresh-token", + expires: Date.now() + 3600000, }) - } finally { - if (prev !== undefined) { - await Filesystem.write(authPath, prev) - } - if (prev === undefined) { - await unlink(authPath).catch(() => undefined) - } - } -}) + yield* ModelCache.Service.use((cache) => cache.fetch("kilo")).pipe(Effect.provide(layer(info, captured))) + expect(yield* Ref.get(captured)).toMatchObject({ kilocodeToken: "test-personal-token" }) + expect((yield* Ref.get(captured))?.kilocodeOrganizationId).toBeUndefined() + }), +) + +it.live("ModelCache.clear removes cached entry so next fetch hits the network", () => + Effect.gen(function* () { + const captured = yield* Ref.make(undefined) + const info = new Auth.Oauth({ + type: "oauth", + access: "token-clear-test", + refresh: "refresh-clear", + expires: Date.now() + 3600000, + accountId: "org-clear", + }) + yield* ModelCache.Service.use((cache) => + Effect.gen(function* () { + yield* cache.fetch("kilo") + expect(yield* Ref.get(captured)).toBeDefined() + + yield* Ref.set(captured, undefined) + yield* cache.fetch("kilo") + expect(yield* Ref.get(captured)).toBeUndefined() + expect(yield* cache.get("kilo")).toBeDefined() + + yield* cache.clear("kilo") + expect(yield* cache.get("kilo")).toBeUndefined() + + yield* cache.fetch("kilo") + expect(yield* Ref.get(captured)).toBeDefined() + }), + ).pipe(Effect.provide(layer(info, captured))) + }), +) diff --git a/packages/opencode/test/kilocode/provider-list-failed-state.test.ts b/packages/opencode/test/kilocode/provider-list-failed-state.test.ts index c51738a226..26b37353d9 100644 --- a/packages/opencode/test/kilocode/provider-list-failed-state.test.ts +++ b/packages/opencode/test/kilocode/provider-list-failed-state.test.ts @@ -4,124 +4,152 @@ // 2. ModelCache.getFailure() returns the typed error for a failed provider. // 3. Clear removes failure state. -import { beforeEach, test, expect, mock } from "bun:test" -import { Effect } from "effect" -import path from "path" +import { beforeEach, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { FetchHttpClient } from "effect/unstable/http" import * as Log from "@opencode-ai/core/util/log" Log.init({ print: false }) -// Stub fetchKiloModels to return controlled typed results. -let stubbedResult: { models: Record; error?: { kind: string; status?: number } } = { models: {} } -let stubbedError: Error | undefined - -mock.module("@kilocode/kilo-gateway", () => ({ - fetchKiloModels: async () => { - if (stubbedError) throw stubbedError - return stubbedResult - }, - KILO_OPENROUTER_BASE: "https://api.kilo.ai/api/openrouter", -})) - -mock.module("opencode-copilot-auth", () => ({ default: () => ({}) })) -mock.module("opencode-anthropic-auth", () => ({ default: () => ({}) })) -mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: () => ({}) })) - -import { tmpdir } from "../fixture/fixture" -import { WithInstance } from "../../src/project/with-instance" +import { Auth } from "../../src/auth" import { ModelCache } from "../../src/provider/model-cache" -import { AppRuntime } from "../../src/effect/app-runtime" +import type { Provider } from "../../src/provider/models" +import { TestConfig } from "../fixture/config" +import { testEffect } from "../lib/effect" -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))) +type Failure = { kind: "unauthorized" | "network" | "schema" | "http"; status?: number } +type Result = { models: Provider["models"]; error?: Failure } -const CONFIG = JSON.stringify({ $schema: "https://app.kilo.ai/config.json" }) +let result: Result = { models: {} } +let error: Error | undefined -async function withInstance(fn: () => Promise): Promise { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "kilo.json"), CONFIG) - }, - }) - return WithInstance.provide({ directory: tmp.path, fn }) +const auth = Layer.mock(Auth.Service)({ + get: () => Effect.succeed(undefined), +}) + +function layer() { + const models = Layer.succeed( + ModelCache.KiloModelsService, + ModelCache.KiloModelsService.of({ + fetch: () => (error ? Effect.fail(error) : Effect.succeed(result)), + }), + ) + return Layer.fresh(ModelCache.layer).pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(TestConfig.layer()), + Layer.provide(auth), + Layer.provide(models), + ) } +const it = testEffect(Layer.empty) + beforeEach(() => { - stubbedError = undefined + result = { models: {} } + error = undefined }) -test("failedProviders returns empty array when no fetch has occurred", async () => { - await clear("kilo") - expect(await failed()).not.toContain("kilo") -}) +it.live("failedProviders returns empty array when no fetch has occurred", () => + ModelCache.Service.use((cache) => + Effect.gen(function* () { + expect(yield* cache.failedProviders()).not.toContain("kilo") + }), + ).pipe(Effect.provide(layer())), +) -test("getFailure returns undefined when fetch succeeds", async () => { - stubbedResult = { - models: { - "test/model": { - id: "test/model", - name: "Test", - cost: { input: 1, output: 2 }, - limit: { context: 128000, output: 4096 }, +it.live("getFailure returns undefined when fetch succeeds", () => + Effect.gen(function* () { + result = { + models: { + "test/model": { + id: "test/model", + name: "Test", + attachment: false, + reasoning: false, + release_date: "", + temperature: true, + tool_call: true, + cost: { input: 1, output: 2 }, + limit: { context: 128000, output: 4096 }, + }, }, - }, - } - await clear("kilo") - await withInstance(() => fetch("kilo")) - expect(await failure("kilo")).toBeUndefined() - expect(await failed()).not.toContain("kilo") -}) + } + yield* ModelCache.Service.use((cache) => + Effect.gen(function* () { + yield* cache.fetch("kilo") + expect(yield* cache.getFailure("kilo")).toBeUndefined() + expect(yield* cache.failedProviders()).not.toContain("kilo") + }), + ).pipe(Effect.provide(layer())) + }), +) -test("failedProviders includes provider after auth error", async () => { - stubbedResult = { models: {}, error: { 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 }) -}) +it.live("failedProviders includes provider after auth error", () => + Effect.gen(function* () { + result = { models: {}, error: { kind: "unauthorized", status: 401 } } + yield* ModelCache.Service.use((cache) => + Effect.gen(function* () { + yield* cache.fetch("kilo") + expect(yield* cache.failedProviders()).toContain("kilo") + expect(yield* cache.getFailure("kilo")).toMatchObject({ kind: "unauthorized", status: 401 }) + }), + ).pipe(Effect.provide(layer())) + }), +) -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({}) -}) +it.live("gateway rejection remains recoverable through the Effect error channel", () => + Effect.gen(function* () { + error = new Error("gateway failed") + yield* ModelCache.Service.use((cache) => + Effect.gen(function* () { + const models = yield* cache.fetch("kilo").pipe(Effect.catch(() => Effect.succeed({}))) + expect(models).toEqual({}) + }), + ).pipe(Effect.provide(layer())) + }), +) -test("clear removes failure state", async () => { - stubbedResult = { models: {}, error: { kind: "network" } } - await clear("kilo") - await withInstance(() => fetch("kilo")) - expect(await failed()).toContain("kilo") +it.live("clear removes failure state", () => + Effect.gen(function* () { + result = { models: {}, error: { kind: "network" } } + yield* ModelCache.Service.use((cache) => + Effect.gen(function* () { + yield* cache.fetch("kilo") + expect(yield* cache.failedProviders()).toContain("kilo") + yield* cache.clear("kilo") + expect(yield* cache.failedProviders()).not.toContain("kilo") + expect(yield* cache.getFailure("kilo")).toBeUndefined() + }), + ).pipe(Effect.provide(layer())) + }), +) - 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 } } - await clear("kilo") - await withInstance(() => fetch("kilo")) - expect(await failed()).toContain("kilo") - - stubbedResult = { - models: { - "test/model": { - id: "test/model", - name: "Test", - cost: { input: 1, output: 2 }, - limit: { context: 128000, output: 4096 }, - }, - }, - } - await clear("kilo") - await withInstance(() => fetch("kilo")) - expect(await failed()).not.toContain("kilo") - expect(await failure("kilo")).toBeUndefined() -}) +it.live("failure state is cleared when subsequent refresh succeeds", () => + Effect.gen(function* () { + result = { models: {}, error: { kind: "unauthorized", status: 401 } } + yield* ModelCache.Service.use((cache) => + Effect.gen(function* () { + yield* cache.fetch("kilo") + expect(yield* cache.failedProviders()).toContain("kilo") + result = { + models: { + "test/model": { + id: "test/model", + name: "Test", + attachment: false, + reasoning: false, + release_date: "", + temperature: true, + tool_call: true, + cost: { input: 1, output: 2 }, + limit: { context: 128000, output: 4096 }, + }, + }, + } + yield* cache.refresh("kilo") + expect(yield* cache.failedProviders()).not.toContain("kilo") + expect(yield* cache.getFailure("kilo")).toBeUndefined() + }), + ).pipe(Effect.provide(layer())) + }), +)