mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
fix: address org model selection review findings
This commit is contained in:
@@ -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"],
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user