fix: address org model selection review findings

This commit is contained in:
webreflection
2026-09-02 13:39:16 +02:00
parent 354f58efe5
commit b77e9824ba
10 changed files with 613 additions and 47 deletions
+9 -7
View File
@@ -2531,7 +2531,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private invalidateProviders(): void {
this.providersGeneration++
this.providersQueued = false
this.cachedProvidersMessage = null
this.postMessage({ type: "providersLoading" })
}
@@ -4547,14 +4546,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private async reloadAfterAuthChange(): Promise<void> {
this.invalidateProviderUsage()
this.invalidateProviders()
await this.fetchAndSendConfig()
await Promise.all([
this.fetchAndSendProviders(),
this.fetchAndSendAgents(),
this.fetchAndSendSkills(),
this.fetchAndSendCommands(),
this.fetchAndSendIndexingStatus(),
this.fetchAndSendNotifications(),
this.fetchAndSendConfig().then(() =>
Promise.all([
this.fetchAndSendAgents(),
this.fetchAndSendSkills(),
this.fetchAndSendCommands(),
this.fetchAndSendIndexingStatus(),
this.fetchAndSendNotifications(),
]),
),
])
}
@@ -58,7 +58,10 @@ const { MemoryProvider } = await import("../../webview-ui/src/context/memory")
const { SpeechToTextModelsProvider } = await import("../../webview-ui/src/context/speech-to-text-models")
const { drafts, imageDrafts, savePromptDraft } = await import("../../webview-ui/src/utils/draft-store")
const [settings, setSettings] = createSignal<{ model?: string; agent?: Record<string, { model?: string }> }>({})
const [settings, setSettings] = createSignal<{
model?: string
agent?: Record<string, { model?: string; variant?: string }>
}>({})
const config = {
config: settings,
globalConfig: () => ({}),
@@ -209,7 +212,7 @@ const settle = async () => {
await window.happyDOM.waitUntilComplete()
}
const emit = async (data: unknown) => {
post(data)
post(structuredClone(data))
await settle()
}
const state = (id: string) => {
@@ -304,7 +307,11 @@ try {
organizationId,
ready,
providers: {
kilo: { id: "kilo", name: "Kilo", models: Object.fromEntries(ids.map((id) => [id, { id, name: id }])) },
kilo: {
id: "kilo",
name: "Kilo",
models: Object.fromEntries(ids.map((id) => [id, { id, name: id, variants: { low: {}, high: {} } }])),
},
openai: { id: "openai", name: "OpenAI", models: { external: { id: "external", name: "External" } } },
},
connected: ["kilo", "openai"],
@@ -475,6 +482,239 @@ try {
choice(value.selected(), recommended)
assert.deepEqual(writes(), remembered)
const snapshot = (scope?: string) =>
JSON.stringify({
session: value.currentSessionID(),
draft: value.draftSessionID(),
agent: value.selectedAgent(scope),
model: value.selected(scope),
variant: value.currentVariant(scope),
foreground: [value.selectedAgent(), value.selected(), value.currentVariant()],
modes: ["code", "ask"].map((name) => [
value.modelForAgent(name),
value.variantForAgent(name, value.modelForAgent(name)),
]),
recents: value.recentModels(),
usage: value.modelUsageHistory(),
sessions: value.sessions(),
messages: value.allMessages(),
submitting: value.submitting(),
cleared: value.userClearedSession(),
})
await catalog("org-a", [personal.modelID, first.modelID, recommended.modelID], recommended.modelID)
await emit({ type: "modelSelectionsLoaded", selections: { code: first, ask: recommended } })
value.selectAgent("ask")
value.selectVariant("low")
for (const scope of [undefined, "ses_command", "ses_background-command", "command-draft"]) {
value.setCurrentSessionID(undefined)
value.selectAgent("code")
value.selectVariant("low")
if (scope) {
value.setSessionAgent(scope, "code")
value.setSessionModel(scope, personal.providerID, personal.modelID)
value.selectVariant("low", scope)
}
value.setCurrentSessionID(
scope === "ses_background-command" ? "selection" : scope === "command-draft" ? undefined : scope,
)
value.setDraftSessionID(scope === "command-draft" ? scope : undefined)
await settle()
const initial = snapshot(scope)
for (const reason of ["retained", "loading", "empty", "invalid", "malformed"]) {
if (reason === "retained")
await catalog("org-a", [personal.modelID, first.modelID, recommended.modelID], recommended.modelID, false)
if (reason === "loading") await emit({ type: "providersLoading" })
if (reason === "empty") await catalog("org-a", [])
const before = snapshot(scope)
const count = sent.length
assert.equal(
value.sendCommand(
"review-test",
"preserve selection",
personal.providerID,
personal.modelID,
undefined,
scope === "command-draft" ? scope : undefined,
undefined,
scope === "command-draft" ? null : scope,
{
agent: "ask",
model: reason === "invalid" ? "kilo/unavailable" : reason === "malformed" ? "invalid" : undefined,
variant: "high",
},
),
false,
`${scope ?? "new"}: ${reason}`,
)
await settle()
assert.equal(snapshot(scope), before, `${scope ?? "new"}: ${reason} mutated selection`)
assert.deepEqual(sent.slice(count), [], "Rejected commands must not persist, seed, or send")
await catalog("org-a", [personal.modelID, first.modelID, recommended.modelID], recommended.modelID)
assert.equal(snapshot(scope), initial, "Restoring the catalog must restore the untouched model and variant")
}
}
for (const configured of [false, true]) {
const scope = `ses_command-${configured ? "configured" : "preferred"}`
setSettings(configured ? { agent: { ask: { model: "kilo/z-first", variant: "high" } } } : {})
await emit({ type: "modelSelectionsLoaded", selections: { code: first, ask: recommended } })
value.setCurrentSessionID(scope)
value.setSessionAgent(scope, "code")
value.setSessionModel(scope, personal.providerID, personal.modelID)
await settle()
assert.equal(
value.sendCommand(
"review-test",
"agent model",
personal.providerID,
personal.modelID,
undefined,
undefined,
undefined,
undefined,
{ agent: "ask" },
),
true,
)
const request = requests().at(-1)
assert(request?.type === "sendCommand")
assert.equal(request.sessionID, scope)
assert.equal(request.agent, "ask")
assert.equal(request.modelID, configured ? first.modelID : recommended.modelID)
assert.equal(request.variant, configured ? "high" : "low")
assert.equal(value.selectedAgent(scope), "ask")
choice(value.selected(scope), configured ? first : recommended)
}
setSettings({})
await catalog(null, [auto.modelID, personal.modelID, first.modelID, recommended.modelID])
await emit({ type: "modelSelectionsLoaded", selections: {} })
value.setCurrentSessionID(undefined)
value.selectAgent("ask")
await settle()
value.selectAgent("code")
await settle()
setSettings({ agent: { ask: { model: "kilo/a-recommended", variant: "high" } } })
value.setCurrentSessionID("ses_command-cached")
await settle()
assert.equal(
value.sendCommand(
"review-test",
"configured mode",
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
{ agent: "ask" },
),
true,
)
const configured = requests().at(-1)
assert(configured?.type === "sendCommand")
assert.equal(configured.modelID, recommended.modelID)
assert.equal(configured.variant, "high")
choice(value.selected(), recommended)
setSettings({})
await catalog("org-a", [personal.modelID, first.modelID, recommended.modelID], recommended.modelID)
await emit({ type: "modelSelectionsLoaded", selections: { code: first, ask: recommended } })
value.setCurrentSessionID(undefined)
value.selectAgent("ask")
value.setCurrentSessionID("selection")
assert.equal(
value.sendCommand(
"review-test",
"pending agent",
personal.providerID,
personal.modelID,
undefined,
undefined,
undefined,
null,
{ variant: "high" },
),
true,
)
const pending = requests().at(-1)
assert(pending?.type === "sendCommand")
assert(pending.draftID)
assert.equal(pending.sessionID, undefined)
assert.equal(pending.agent, "ask")
assert.equal(pending.modelID, recommended.modelID)
assert.equal(pending.variant, "high")
assert.equal(value.selectedAgent(pending.draftID), "ask")
choice(value.selected(pending.draftID), recommended)
assert.equal(value.currentVariant(pending.draftID), "high")
assert.equal(value.variantForAgent("ask", recommended), "low")
const persisted = sent.length
assert.equal(
value.sendCommand(
"review-test",
"explicit model",
first.providerID,
first.modelID,
undefined,
undefined,
undefined,
null,
{ agent: "ask", model: "kilo/personal", variant: "high" },
),
true,
)
const accepted = requests().at(-1)
assert(accepted?.type === "sendCommand")
assert.equal(accepted.sessionID, undefined)
assert(accepted.draftID)
assert.equal(accepted.agent, "ask")
assert.equal(accepted.modelID, personal.modelID)
assert.equal(accepted.variant, "high")
assert.equal(value.currentSessionID(), "selection")
assert.equal(value.draftSessionID(), accepted.draftID)
choice(value.selected(accepted.draftID), personal)
assert.equal(value.selectedAgent(accepted.draftID), "ask")
assert.equal(value.currentVariant(accepted.draftID), "high")
choice(value.modelForAgent("ask"), recommended)
assert.equal(
sent.slice(persisted).some((message) => message.type === "persistModelSelection"),
false,
)
await emit({ type: "sessionCreated", session: info("ses_command-promoted"), draftID: accepted.draftID })
choice(value.selected("ses_command-promoted"), personal)
assert.equal(value.selectedAgent("ses_command-promoted"), "ask")
assert.equal(value.currentVariant("ses_command-promoted"), "high")
assert(
sent
.slice(persisted)
.some(
(message) =>
message.type === "persistVariant" &&
message.key === "session/ses_command-promoted/kilo/personal" &&
message.value === "high",
),
)
assert.equal(
value.sendCommand(
"review-test",
"scoped draft",
first.providerID,
first.modelID,
undefined,
"command-draft",
undefined,
null,
),
true,
)
const scoped = requests().at(-1)
assert(scoped?.type === "sendCommand")
assert.equal(scoped.draftID, "command-draft")
assert.equal(scoped.agent, "code")
assert.equal(scoped.modelID, personal.modelID)
assert.equal(scoped.variant, "low")
assert.equal(value.currentSessionID(), "ses_command-promoted")
value.setDraftSessionID(undefined)
const key = "acceptance:session:composer"
const image = { id: "image", filename: "image.png", mime: "image/png", dataUrl: "data:image/png;base64,cGl4ZWw=" }
const input = () => {
@@ -1,34 +1,65 @@
import { describe, expect, it } from "bun:test"
import type { Config } from "@kilocode/sdk/v2/client"
import type { AuthContext } from "../../src/kilo-provider/handlers/auth"
const { KiloProvider } = await import("../../src/KiloProvider")
const external = { id: "external", name: "External", models: { model: { id: "model" } } }
const catalog = (org: string) => ({
data: {
all: [{ id: "kilo", name: "Kilo Gateway", models: { [`${org}/model`]: { id: `${org}/model` } } }],
connected: ["kilo"],
default: { kilo: "kilo-auto/free" },
all: [{ id: "kilo", name: "Kilo Gateway", models: { [`${org}/model`]: { id: `${org}/model` } } }, external],
connected: ["kilo", "external"],
default: { kilo: "kilo-auto/free", external: "model" },
},
})
type Internals = {
connectionState: string
cachedConfigMessage: unknown
cachedProvidersMessage: unknown
providersRefresh: Promise<void> | null
authCtx: AuthContext
fetchAndSendProviders(): Promise<void>
invalidateProviders(): void
handleEvent(event: unknown, directory?: string): void
reloadAfterAuthChange(): Promise<void>
}
function setup(list: () => Promise<ReturnType<typeof catalog>>, org: () => string) {
const client = {
provider: { list, auth: async () => ({ data: {} }) },
kilo: { authStatus: async () => ({ data: { authenticated: true, type: "oauth", organizationId: org() } }) },
config: { providers: async () => ({ data: { default: { kilo: `${org()}/model` } } }) },
config: {
get: async (): Promise<{ data: Config }> => ({ data: {} }),
overlay: async () => ({ data: {} }),
providers: async () => ({ data: { default: { kilo: `${org()}/model` } } }),
},
global: { config: { get: async () => ({ data: {} }) } },
experimental: { capabilities: { get: async () => ({ data: {} }) } },
}
const provider = new KiloProvider({} as never, { getClient: () => client } as never)
const provider = new KiloProvider(
{} as never,
{ getClient: () => client, resolveEventSessionId: () => undefined } as never,
)
const internal = provider as unknown as Internals
internal.connectionState = "connected"
Object.assign(internal, {
connectionState: "connected",
fetchAndSendAgents: async () => {},
fetchAndSendSkills: async () => {},
fetchAndSendCommands: async () => {},
fetchAndSendIndexingStatus: async () => {},
fetchAndSendNotifications: async () => {},
})
const reloads: Promise<void>[] = []
const reload = internal.reloadAfterAuthChange.bind(internal)
internal.reloadAfterAuthChange = () => {
const task = reload()
reloads.push(task)
return task
}
const messages: Array<Record<string, unknown>> = []
provider.postMessage = (message) => void messages.push(message as Record<string, unknown>)
return { internal, messages }
return { internal, messages, client, reloads }
}
describe("KiloProvider catalog refresh", () => {
@@ -79,6 +110,117 @@ describe("KiloProvider catalog refresh", () => {
})
})
it.each([false, true])("preserves a queued refresh through auth invalidation (failure: %s)", async (fail) => {
const first = Promise.withResolvers<ReturnType<typeof catalog>>()
let org = "a"
let calls = 0
const { internal, messages } = setup(
async () => (++calls === 1 ? first.promise : catalog(org)),
() => org,
)
const before = internal.fetchAndSendProviders()
const queued = internal.fetchAndSendProviders()
org = "b"
internal.authCtx.invalidateProviders()
if (fail) first.reject(new Error("Old catalog unavailable"))
if (!fail) first.resolve(catalog("a"))
await Promise.all([before, queued])
expect(calls).toBe(2)
expect(messages).toHaveLength(2)
expect(messages.at(0)).toEqual({ type: "providersLoading" })
expect(messages.at(-1)).toMatchObject({
type: "providersLoaded",
organizationId: "b",
ready: true,
defaults: { kilo: "b/model" },
providers: { kilo: { models: { "b/model": { id: "b/model" } } }, external },
})
})
it.each(["global.disposed", "server.instance.disposed"])(
"%s restores a fresh catalog without waiting for config",
async (type) => {
const config = Promise.withResolvers<{ data: Config }>()
const { internal, messages, client, reloads } = setup(
async () => catalog("org"),
() => "org",
)
const preference = { model: "external/model" }
internal.cachedConfigMessage = { config: preference }
client.config.get = () => config.promise
await internal.fetchAndSendProviders()
const fresh = internal.cachedProvidersMessage
internal.handleEvent(
{ type, properties: { directory: "/repo" } },
type === "global.disposed" ? "global" : "/repo",
)
try {
expect(messages.at(-1)).toEqual({ type: "providersLoading" })
expect(internal.providersRefresh).not.toBeNull()
await internal.providersRefresh
expect(internal.cachedProvidersMessage).toEqual(fresh)
expect(messages.at(-1)).toMatchObject({
type: "providersLoaded",
ready: true,
providers: { external },
defaultSelection: { providerID: "external", modelID: "model" },
})
expect(messages.some((message) => message.type === "configLoaded")).toBe(false)
expect(internal.cachedConfigMessage).toEqual({ config: preference })
} finally {
config.resolve({ data: preference })
await Promise.all(reloads)
}
expect(internal.cachedProvidersMessage).toEqual(fresh)
},
)
it("global disposal invalidates every view and retries only the new Org while config is delayed", async () => {
const config = Promise.withResolvers<{ data: Config }>()
const first = Promise.withResolvers<ReturnType<typeof catalog>>()
let org = "a"
let delayed = false
const views = Array.from({ length: 2 }, () =>
setup(
async () => (delayed && org === "a" ? first.promise : catalog(org)),
() => org,
),
)
await Promise.all(views.map((view) => view.internal.fetchAndSendProviders()))
delayed = true
const pending = views.map((view) => view.internal.fetchAndSendProviders())
const queued = views.map((view) => view.internal.fetchAndSendProviders())
org = "b"
views.at(0)!.internal.authCtx.invalidateProviders()
for (const view of views) {
view.client.config.get = () => config.promise
view.internal.handleEvent({ type: "global.disposed", properties: {} }, "global")
expect(view.internal.cachedProvidersMessage).toBeNull()
expect(view.messages.at(-1)).toEqual({ type: "providersLoading" })
}
first.resolve(catalog("a"))
try {
await Promise.all([...pending, ...queued])
for (const view of views) {
expect(view.messages.filter((message) => message.type === "providersLoaded")).toHaveLength(2)
expect(view.internal.cachedProvidersMessage).toMatchObject({
organizationId: "b",
ready: true,
defaults: { kilo: "b/model" },
providers: { kilo: { models: { "b/model": { id: "b/model" } } }, external },
})
expect(view.messages.some((message) => message.type === "configLoaded")).toBe(false)
}
} finally {
config.resolve({ data: {} })
await Promise.all(views.flatMap((view) => view.reloads))
}
})
it("cannot republish an in-flight old catalog after invalidation", async () => {
const first = Promise.withResolvers<ReturnType<typeof catalog>>()
const { internal, messages } = setup(
@@ -150,13 +150,16 @@ describe("KiloProvider indexing refresh", () => {
])
})
it("reloadAfterAuthChange fetches config first, then indexing status", async () => {
it("reloadAfterAuthChange refreshes providers immediately but waits for config before indexing", async () => {
const provider = new KiloProvider({} as never, {} as never)
const internal = provider as unknown as Internals
const calls: string[] = []
const config = Promise.withResolvers<void>()
internal.fetchAndSendConfig = async () => {
calls.push("config")
await config.promise
calls.push("configured")
}
internal.fetchAndSendProviders = async () => {
calls.push("providers")
@@ -177,10 +180,17 @@ describe("KiloProvider indexing refresh", () => {
calls.push("indexing")
}
await internal.reloadAfterAuthChange()
const pending = internal.reloadAfterAuthChange()
try {
expect(calls).toContain("providers")
expect(calls).toContain("config")
expect(calls).not.toContain("indexing")
} finally {
config.resolve()
await pending
}
expect(calls[0]).toBe("config")
expect(calls.includes("indexing")).toBe(true)
expect(calls.indexOf("indexing")).toBeGreaterThan(calls.indexOf("configured"))
})
it("handleUpdateConfig no longer eagerly fetches indexing status", async () => {
@@ -85,7 +85,7 @@ describe("sendCommand dismisses pending tool requests", () => {
expect(body).toContain("if (overrides?.agent)")
expect(body).toContain("selectAgent(overrides.agent, scope)")
expect(body).toContain("if (overrides?.model)")
expect(body).toContain("selectModel(parsed.providerID, parsed.modelID, scope)")
expect(body).toContain("selectModel(effectiveSelection.providerID, effectiveSelection.modelID, scope)")
expect(body).toContain("if (overrides?.variant)")
expect(body).toContain("selectVariant(overrides.variant, scope)")
})
@@ -2169,6 +2169,18 @@ export const SessionProvider: ParentComponent = (props) => {
}
const sid = origin === undefined ? currentSessionID() : (origin ?? undefined)
const effectiveSelection = (() => {
if (overrides?.model) return parseModelString(overrides.model)
const scope = draftID ?? sid
const model = overrides?.agent
? modelForAgent(overrides.agent)
: scope
? selected(scope)
: getSelected(preferences(), environment(), undefined, pendingAgentSelection() ?? defaultAgent())
return model ?? (providerID && modelID ? { providerID, modelID } : null)
})()
if (!available(effectiveSelection)) return false
const effectiveDraftID = !sid && !draftID ? crypto.randomUUID() : draftID
const scope = effectiveDraftID ?? sid
if (!sid && !draftID && effectiveDraftID) agentDrafts.seed(effectiveDraftID)
@@ -2177,16 +2189,12 @@ export const SessionProvider: ParentComponent = (props) => {
selectAgent(overrides.agent, scope)
}
if (overrides?.model) {
const parsed = parseModelString(overrides.model)
if (!available(parsed)) return false
selectModel(parsed.providerID, parsed.modelID, scope)
selectModel(effectiveSelection.providerID, effectiveSelection.modelID, scope)
}
if (overrides?.variant) {
selectVariant(overrides.variant, scope)
}
const effectiveSelection = selected(scope) ?? (providerID && modelID ? { providerID, modelID } : null)
if (!available(effectiveSelection)) return false
const effectiveProvider = effectiveSelection.providerID
const effectiveModel = effectiveSelection.modelID
recordModelUsage(effectiveProvider, effectiveModel)
@@ -87,8 +87,11 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo",
const cfg = yield* config.get()
const organizationId = catalogOrganization(cfg.provider?.kilo?.options, info)
const type = getToken(info) && (info?.type === "api" || info?.type === "oauth") ? info.type : undefined
if (!type) return { authenticated: false, organizationId }
return { authenticated: true, type, organizationId }
return {
authenticated: !!type,
...(type ? { type } : {}),
...(organizationId == null ? {} : { organizationId }),
}
})
const proxyAuth = Effect.fn("KiloGatewayHttpApi.proxyAuth")(function* () {
@@ -7,6 +7,7 @@ import { mapValues, pickBy } from "remeda" // kilocode_change
import { ModelCache } from "@/provider/model-cache" // kilocode_change
import { Auth } from "@/auth" // kilocode_change
import { organization } from "@/kilocode/provider/catalog" // kilocode_change
import { Option } from "effect" // kilocode_change
import {
disposeAllInstancesAfterProviderAuthCallback,
invalidatePresence,
@@ -60,8 +61,8 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
}
const connected = yield* provider.list()
// kilocode_change start
const info = yield* access.get("kilo").pipe(Effect.orDie)
if (organization(config.provider?.kilo?.options, info)) delete filtered.kilo
const info = yield* access.get("kilo").pipe(Effect.option)
if (Option.isNone(info) || organization(config.provider?.kilo?.options, info.value)) delete filtered.kilo
const providers = filterPromptTrainingModels(
Object.assign(
mapValues(filtered, (item) => Provider.fromModelsDevProvider(item)),
@@ -69,6 +70,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
),
config.hide_prompt_training_models === true,
)
if (Option.isNone(info)) delete providers.kilo
// kilocode_change end
// kilocode_change start
const failed = yield* cache.failedProviders()
@@ -85,7 +87,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
metadata: providerMetadata(item.id),
})), // kilocode_change
default: Provider.defaultModelIDs(pickBy(validProviders, (item) => Object.keys(item.models).length > 0)),
connected: Object.keys(connected),
connected: Object.keys(connected).filter((id) => id !== "kilo" || Option.isSome(info)),
failed,
}
// kilocode_change end
@@ -114,7 +114,9 @@ describe("Kilo gateway HttpApi statuses", () => {
}),
)
for (const context of ["config", "oauth", "env", "url", "personal", "anonymous"] as const) {
for (const context of ["config", "oauth", "env", "url", "personal", "anonymous", "anonymous-personal"] as const) {
const anonymous = context.startsWith("anonymous")
const personal = context.endsWith("personal")
it.live(`reports ${context} organization context locally without secrets`, () =>
Effect.gen(function* () {
const previous = { ...state }
@@ -127,17 +129,16 @@ describe("Kilo gateway HttpApi statuses", () => {
: context === "url"
? { provider: { kilo: { options: { baseURL: "https://gateway.test/api/organizations/org-url" } } } }
: {}
state.info =
context === "anonymous"
? undefined
: new Auth.Oauth({
type: "oauth",
access: "test-token",
refresh: "private-refresh",
expires: Date.now() + 3600000,
...(["config", "oauth", "url"].includes(context) ? { accountId: "org-oauth" } : {}),
})
if (context === "personal") delete process.env.KILO_ORG_ID
state.info = anonymous
? undefined
: new Auth.Oauth({
type: "oauth",
access: "test-token",
refresh: "private-refresh",
expires: Date.now() + 3600000,
...(["config", "oauth", "url"].includes(context) ? { accountId: "org-oauth" } : {}),
})
if (personal) delete process.env.KILO_ORG_ID
else process.env.KILO_ORG_ID = "org-env"
}),
() =>
@@ -151,9 +152,9 @@ describe("Kilo gateway HttpApi statuses", () => {
const response = yield* HttpClient.get(KiloGatewayPaths.authStatus)
expect(response.status).toBe(200)
expect(yield* response.json).toEqual({
authenticated: context !== "anonymous",
...(context !== "anonymous" ? { type: "oauth" } : {}),
...(context !== "personal" ? { organizationId: `org-${context === "anonymous" ? "env" : context}` } : {}),
authenticated: !anonymous,
...(!anonymous ? { type: "oauth" } : {}),
...(!personal ? { organizationId: `org-${anonymous ? "env" : context}` } : {}),
})
}),
)
@@ -0,0 +1,158 @@
import { describe, expect } from "bun:test"
import { NodeHttpServer } from "@effect/platform-node"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Effect, Layer } from "effect"
import { HttpClient, HttpRouter } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"
import { Auth } from "../../../src/auth"
import { KiloViewers } from "../../../src/kilocode/presence/service"
import { InstanceStore } from "../../../src/project/instance-store"
import { Session } from "../../../src/session/session"
import { ModelCache } from "../../../src/provider/model-cache"
import { Provider } from "../../../src/provider/provider"
import { ProviderAuth } from "../../../src/provider/auth"
import { ProviderApi } from "../../../src/server/routes/instance/httpapi/groups/provider"
import { providerHandlers } from "../../../src/server/routes/instance/httpapi/handlers/provider"
import { Authorization } from "../../../src/server/routes/instance/httpapi/middleware/authorization"
import { InstanceContextMiddleware } from "../../../src/server/routes/instance/httpapi/middleware/instance-context"
import {
WorkspaceRouteContext,
WorkspaceRoutingMiddleware,
} from "../../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { schemaErrorLayer } from "../../../src/server/routes/instance/httpapi/middleware/schema-error"
import { TestConfig } from "../../fixture/config"
import { testEffect } from "../../lib/effect"
function catalog(id: string): ModelsDev.Provider {
return {
id,
name: id,
env: [],
models: {
model: {
id: "model",
name: "Model",
release_date: "2026-01-01",
attachment: false,
reasoning: false,
temperature: true,
tool_call: true,
limit: { context: 32000, output: 4096 },
},
},
}
}
const catalogs = { external: catalog("external"), kilo: catalog("kilo") }
const providers = {
external: Provider.fromModelsDevProvider(catalogs.external),
kilo: Provider.fromModelsDevProvider(catalogs.kilo),
}
const state = { failure: false, connected: true }
const layer = HttpRouter.serve(
HttpApiBuilder.layer(HttpApi.make("opencode-instance").addHttpApi(ProviderApi)).pipe(
Layer.provide(providerHandlers),
Layer.provide(schemaErrorLayer),
Layer.provide([
TestConfig.layer({ get: () => Effect.succeed({ enabled_providers: ["external", "kilo"] }) }),
Layer.mock(Provider.Service)({
list: () => Effect.succeed(state.connected ? providers : { external: providers.external }),
}),
Layer.mock(ProviderAuth.Service)({}),
Layer.mock(ModelCache.Service)({ failedProviders: () => Effect.succeed([]) }),
Layer.mock(Auth.Service)({
get: () =>
state.failure
? Effect.fail(new Auth.AuthError({ message: "Cannot read credentials" }))
: Effect.succeed(undefined),
}),
Layer.succeed(
Authorization,
Authorization.of((effect) => effect),
),
Layer.succeed(
InstanceContextMiddleware,
InstanceContextMiddleware.of((effect) => effect),
),
Layer.succeed(
WorkspaceRoutingMiddleware,
WorkspaceRoutingMiddleware.of((effect) =>
effect.pipe(
Effect.provideService(WorkspaceRouteContext, WorkspaceRouteContext.of({ directory: process.cwd() })),
),
),
),
]),
),
{ disableListenLog: true, disableLogger: true },
).pipe(
Layer.provide([
Layer.mock(ModelsDev.Service)({ get: () => Effect.succeed(catalogs) }),
Layer.mock(InstanceStore.Service)({}),
Layer.mock(Session.Service)({}),
Layer.mock(KiloViewers.Service)({}),
]),
Layer.provideMerge(NodeHttpServer.layerTest),
)
const it = testEffect(layer)
function configure(failure: boolean, connected: boolean) {
return Effect.acquireRelease(
Effect.sync(() => {
const previous = { state: { ...state }, env: process.env.KILO_ORG_ID }
Object.assign(state, { failure, connected })
delete process.env.KILO_ORG_ID
return previous
}),
(previous) =>
Effect.sync(() => {
Object.assign(state, previous.state)
if (previous.env === undefined) {
delete process.env.KILO_ORG_ID
return
}
process.env.KILO_ORG_ID = previous.env
}),
)
}
describe("provider catalog authentication failures", () => {
for (const connected of [false, true]) {
it.live(`keeps other providers available when Kilo auth fails (connected: ${connected})`, () =>
Effect.gen(function* () {
yield* configure(true, connected)
const response = yield* HttpClient.get("/provider")
expect(response.status).toBe(200)
const body = yield* response.json
expect(body).toMatchObject({
all: [{ id: "external" }],
default: { external: "model" },
connected: ["external"],
})
expect(JSON.stringify(body)).not.toContain('"kilo"')
state.failure = false
const recovered = yield* HttpClient.get("/provider")
expect(recovered.status).toBe(200)
expect(yield* recovered.json).toMatchObject({
all: [{ id: "external" }, { id: "kilo" }],
default: { external: "model", kilo: "model" },
connected: connected ? ["external", "kilo"] : ["external"],
})
}),
)
}
it.live("keeps the Personal catalog when credentials are absent rather than unreadable", () =>
Effect.gen(function* () {
yield* configure(false, false)
const response = yield* HttpClient.get("/provider")
expect(response.status).toBe(200)
expect(yield* response.json).toMatchObject({
all: [{ id: "external" }, { id: "kilo" }],
default: { external: "model", kilo: "model" },
connected: ["external"],
})
}),
)
})