feat(agent-manager): assign models to workflows (#12729)

* feat(agent-manager): assign models to workflows

* fix(agent-manager): address workflow model review findings

* fix(cli): handle command source aliases safely

* test(pty): allow slower macOS startup

* test(cli): tolerate transient Windows response reads

* test(cli): bound transient stall response retries

* test(pty): include replayed output in wait helper

* test(pty): stabilize buffered output waits

* test(pty): avoid locale-dependent UTF-8 fixture
This commit is contained in:
Marius
2026-08-03 16:46:07 +02:00
committed by GitHub
parent dfc1607693
commit ce7984fc42
48 changed files with 722 additions and 51 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": minor
"kilo-code": minor
---
Configure a model and reasoning variant for each workflow from Agent Behaviour settings.
+1
View File
@@ -11,6 +11,7 @@ export class Info extends Schema.Class<Info>("CommandV2.Info")({
description: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
model: ModelV2.Ref.pipe(Schema.optional),
variant: ModelV2.VariantID.pipe(Schema.optional), // kilocode_change - support variant-only command overrides
subtask: Schema.Boolean.pipe(Schema.optional),
}) {}
+1 -1
View File
@@ -3,7 +3,7 @@ export * as ConfigCommand from "./command"
import { Schema } from "effect"
export class Info extends Schema.Class<Info>("ConfigV2.Command")({
template: Schema.String,
template: Schema.String.pipe(Schema.optional), // kilocode_change - allow partial command overrides
description: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
model: Schema.String.pipe(Schema.optional),
+26 -15
View File
@@ -29,22 +29,33 @@ export const Plugin = PluginV2.define({
}).pipe(Effect.map((documents) => documents.flat()))
yield* transform((editor) => {
for (const document of documents) {
for (const [name, command] of Object.entries(document.commands ?? {})) {
editor.update(name, (item) => {
item.template = command.template
if (command.description !== undefined) item.description = command.description
if (command.agent !== undefined) item.agent = command.agent
if (command.model !== undefined) {
const model = ModelV2.parse(command.model)
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
}
if (command.variant !== undefined && item.model !== undefined) {
item.model.variant = ModelV2.VariantID.make(command.variant)
}
if (command.subtask !== undefined) item.subtask = command.subtask
})
const items = documents.flatMap((document) => Object.entries(document.commands ?? {}))
// Register every template first, preserving the normal source priority for
// metadata in the second pass. // kilocode_change
for (const [name, command] of items) {
if (command.template === undefined) {
continue
}
const template = command.template
editor.update(name, (item) => {
item.template = template
})
}
for (const [name, command] of items) {
if (command.template === undefined && !editor.get(name)) continue // kilocode_change
editor.update(name, (item) => {
if (command.description !== undefined) item.description = command.description
if (command.agent !== undefined) item.agent = command.agent
if (command.model !== undefined) {
const model = ModelV2.parse(command.model)
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
}
if (command.variant !== undefined) item.variant = ModelV2.VariantID.make(command.variant) // kilocode_change
if (command.variant !== undefined && item.model !== undefined) {
item.model.variant = ModelV2.VariantID.make(command.variant)
}
if (command.subtask !== undefined) item.subtask = command.subtask
})
}
})
}),
+1 -1
View File
@@ -3,7 +3,7 @@ export * as ConfigCommandV1 from "./command"
import { Schema } from "effect"
export const Info = Schema.Struct({
template: Schema.String,
template: Schema.optional(Schema.String), // kilocode_change - allow global workflow model/variant overrides
description: Schema.optional(Schema.String),
agent: Schema.optional(Schema.String),
model: Schema.optional(Schema.String),
+2
View File
@@ -207,6 +207,7 @@ export const SubtaskPart = Schema.Struct({
modelID: ModelV2.ID,
}),
),
variant: Schema.optional(Schema.String), // kilocode_change - preserve workflow subtask variant
command: Schema.optional(Schema.String),
}).annotate({ identifier: "SubtaskPart" })
export type SubtaskPart = Types.DeepMutable<Schema.Schema.Type<typeof SubtaskPart>>
@@ -500,6 +501,7 @@ export const SubtaskPartInput = Schema.Struct({
modelID: ModelV2.ID,
}),
),
variant: Schema.optional(Schema.String), // kilocode_change - preserve workflow subtask variant
command: Schema.optional(Schema.String),
}).annotate({ identifier: "SubtaskPartInput" })
export type SubtaskPartInput = Types.DeepMutable<Schema.Schema.Type<typeof SubtaskPartInput>>
+35
View File
@@ -69,6 +69,7 @@ Review files`,
id: ModelV2.ID.make("claude"),
variant: ModelV2.VariantID.make("high"),
},
variant: ModelV2.VariantID.make("high"),
subtask: true,
}),
new CommandV2.Info({ name: "empty", template: "" }),
@@ -78,4 +79,38 @@ Review files`,
),
),
)
it.effect("applies a global partial override after project command definitions", () =>
Effect.gen(function* () {
const command = yield* CommandV2.Service
yield* ConfigCommandPlugin.Plugin.effect.pipe(
Effect.provideService(CommandV2.Service, command),
Effect.provideService(
Config.Service,
Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({ commands: { review: { model: "anthropic/claude", variant: "high" } } }),
}),
new Config.Document({
type: "document",
info: decode({ commands: { review: { template: "Review files" } } }),
}),
]),
}),
),
)
expect(yield* command.get("review")).toMatchObject({
template: "Review files",
model: {
providerID: ProviderV2.ID.make("anthropic"),
id: ModelV2.ID.make("claude"),
variant: ModelV2.VariantID.make("high"),
},
})
}),
)
})
+7 -4
View File
@@ -11,6 +11,8 @@ import { testEffect } from "../lib/effect"
type PtyEvent = { type: "created" | "exited" | "deleted"; id: PtyID }
const PTY_TEST_TIMEOUT = "15 seconds" // kilocode_change - PTY startup can exceed the default test timeout on macOS CI
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })),
@@ -59,7 +61,7 @@ const waitForEvents = (events: Queue.Queue<PtyEvent>, id: PtyID, count: number)
return picked
}).pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
duration: PTY_TEST_TIMEOUT, // kilocode_change
orElse: () => Effect.fail(new Error("timeout waiting for pty events")),
}),
)
@@ -73,6 +75,7 @@ const attachCollecting = Effect.fn("PtySessionTest.attachCollecting")(function*
onData: (chunk) => Queue.offerUnsafe(output, chunk),
onEnd: (event) => Deferred.doneUnsafe(ended, Effect.succeed(event)),
})
if (attachment.replay) Queue.offerUnsafe(output, attachment.replay)
attachment.activate()
return { attachment, output, ended }
})
@@ -84,7 +87,7 @@ const waitForOutput = (output: Queue.Queue<string>, text: string) =>
return received
}).pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
duration: PTY_TEST_TIMEOUT, // kilocode_change
orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)),
}),
)
@@ -144,7 +147,7 @@ describe("pty", () => {
Effect.gen(function* () {
const pty = yield* Pty.Service
const marker = "café-über-北京-🚀"
const info = yield* createPty("sh", ["-c", `printf '${marker}\\n'`])
const info = yield* createPty("sh", ["-c", "printf 'caf\\303\\251-\\303\\274ber-\\345\\214\\227\\344\\272\\254-\\360\\237\\232\\200\\n'"])
const attached = yield* attachCollecting(info.id)
expect(yield* waitForOutput(attached.output, marker)).toContain(marker)
}),
@@ -268,7 +271,7 @@ describe("pty", () => {
attachment.write("ignored")
yield* pty.remove(info.id)
attachment.activate()
expect(yield* Deferred.await(ended).pipe(Effect.timeout("5 seconds"))).toEqual({ exitCode: 7 })
expect(yield* Deferred.await(ended).pipe(Effect.timeout(PTY_TEST_TIMEOUT))).toEqual({ exitCode: 7 })
attachment.detach()
}),
)
@@ -4,10 +4,16 @@ import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { useConfig } from "../../../context/config"
import { useLanguage } from "../../../context/language"
import { useProvider } from "../../../context/provider"
import { ModelSelectorBase } from "../../shared/ModelSelector"
import { ThinkingSelectorBase } from "../../shared/ThinkingSelector"
import { parseModelString } from "../../../../../src/shared/provider-model"
import type { CommandConfig } from "../../../types/messages"
const WorkflowsTab: Component = () => {
const language = useLanguage()
const { config } = useConfig()
const { config, globalConfig, globalDraft, updateGlobalConfig } = useConfig()
const provider = useProvider()
const cmds = createMemo(() => Object.entries(config().command ?? {}))
const [expanded, setExpanded] = createSignal<Record<string, boolean>>({})
@@ -16,6 +22,38 @@ const WorkflowsTab: Component = () => {
setExpanded((prev) => ({ ...prev, [name]: !prev[name] }))
}
const update = (name: string, patch: Partial<CommandConfig>) => {
updateGlobalConfig({ command: { [name]: patch } })
}
const scoped = (cmd: CommandConfig, name: string) => ({
...cmd,
...globalConfig().command?.[name],
...globalDraft().command?.[name],
})
const model = (cmd: CommandConfig, name: string) => {
const value = scoped(cmd, name).model
return value === null ? null : parseModelString(value ?? undefined)
}
const variant = (cmd: CommandConfig, name: string) => {
const value = scoped(cmd, name).variant
return value === null ? undefined : (value ?? undefined)
}
const variants = (cmd: CommandConfig, name: string) =>
Object.keys(provider.findModel(model(cmd, name))?.variants ?? {})
const selectModel = (name: string, providerID: string, modelID: string) => {
const list = Object.keys(provider.findModel({ providerID, modelID })?.variants ?? {})
const current = variant(config().command?.[name] ?? {}, name)
update(name, {
model: providerID && modelID ? `${providerID}/${modelID}` : null,
...(current && !list.includes(current) ? { variant: null } : {}),
})
}
return (
<div>
{/* Description */}
@@ -118,6 +156,38 @@ const WorkflowsTab: Component = () => {
{cmd.description}
</div>
</Show>
<div
style={{
display: "flex",
"flex-wrap": "wrap",
gap: "8px",
"margin-bottom": "8px",
}}
onClick={(e) => e.stopPropagation()}
>
<ModelSelectorBase
value={model(cmd, name)}
onSelect={(providerID, modelID) => selectModel(name, providerID, modelID)}
placement="bottom-start"
allowClear
clearLabel={language.t("settings.providers.notSet")}
label={`${name} ${language.t("settings.agentBehaviour.workflows.model")}`}
description={language.t("settings.agentBehaviour.workflows.modelDescription")}
/>
<Show when={variants(cmd, name).length > 0 || !!variant(cmd, name)}>
<ThinkingSelectorBase
variants={variants(cmd, name)}
value={variant(cmd, name)}
onSelect={(variant) => update(name, { variant })}
onClear={() => update(name, { variant: null })}
allowClear
clearLabel={language.t("settings.providers.notSet")}
placement="bottom-start"
globalTrigger={false}
label={`${name} ${language.t("settings.agentBehaviour.workflows.variant")}`}
/>
</Show>
</div>
<Show when={cmd.template}>
<div>
<span style={{ "font-weight": "500" }}>
@@ -43,6 +43,8 @@ export interface ThinkingSelectorBaseProps {
globalTrigger?: boolean
/** Show the Shift+Tab cycle hint in the trigger tooltip. */
cycleHint?: boolean
/** Accessible name for the selector trigger. */
label?: string
}
export const ThinkingSelectorBase: Component<ThinkingSelectorBaseProps> = (props) => {
@@ -163,7 +165,7 @@ export const ThinkingSelectorBase: Component<ThinkingSelectorBaseProps> = (props
open={open()}
onOpenChange={onOpen}
triggerAs={Button}
triggerProps={{ variant: "ghost", size: "small" }}
triggerProps={{ variant: "ghost", size: "small", "aria-label": props.label }}
trigger={
<>
<span class="thinking-selector-trigger-label">{display(props.value)}</span>
@@ -40,6 +40,7 @@ export interface SaveError {
interface ConfigContextValue {
config: Accessor<Config>
globalConfig: Accessor<Config>
globalDraft: Accessor<Partial<Config>>
projectConfig: Accessor<Config>
collections: Accessor<ConfigCollections>
settings: Accessor<Record<string, unknown>>
@@ -397,6 +398,7 @@ export const ConfigProvider: ParentComponent = (props) => {
const value: ConfigContextValue = {
config,
globalConfig,
globalDraft,
projectConfig,
collections,
settings,
+3
View File
@@ -1001,6 +1001,9 @@ export const dict = {
"settings.agentBehaviour.workflows.empty": "لم يتم تهيئة أوامر مخصصة. أضف أوامر إلى opencode.json لرؤيتها هنا.",
"settings.agentBehaviour.workflows.detail.description": "الوصف",
"settings.agentBehaviour.workflows.detail.template": "القالب",
"settings.agentBehaviour.workflows.model": "النموذج",
"settings.agentBehaviour.workflows.variant": "المتغير",
"settings.agentBehaviour.workflows.modelDescription": "تجاوز النموذج العام",
"settings.sandboxing.enabled.title": "Sandbox",
"settings.sandboxing.enabled.description":
"تشغيل أوامر shell الخاصة بالوكيل داخل sandbox على مستوى نظام التشغيل يقيّد الكتابة على مجلدات حالة المشروع و Kilo",
+3
View File
@@ -1037,6 +1037,9 @@ export const dict = {
"Nenhum comando personalizado configurado. Adicione comandos ao opencode.json para vê-los aqui.",
"settings.agentBehaviour.workflows.detail.description": "Descrição",
"settings.agentBehaviour.workflows.detail.template": "Modelo",
"settings.agentBehaviour.workflows.model": "modelo",
"settings.agentBehaviour.workflows.variant": "variante",
"settings.agentBehaviour.workflows.modelDescription": "Substituição global do modelo",
"settings.sandboxing.enabled.title": "Sandbox",
"settings.sandboxing.enabled.description":
"Executar os comandos shell do agente dentro de um sandbox a nível de sistema operacional que restringe escritas aos diretórios de estado do projeto e do Kilo",
+3
View File
@@ -1027,6 +1027,9 @@ export const dict = {
"Nema konfiguriranih prilagođenih komandi. Dodajte komande u opencode.json da ih vidite ovdje.",
"settings.agentBehaviour.workflows.detail.description": "Opis",
"settings.agentBehaviour.workflows.detail.template": "Predložak",
"settings.agentBehaviour.workflows.model": "model",
"settings.agentBehaviour.workflows.variant": "varijanta",
"settings.agentBehaviour.workflows.modelDescription": "Globalno premošćivanje modela",
"settings.sandboxing.enabled.title": "Sandbox",
"settings.sandboxing.enabled.description":
"Pokrenite shell komande agenta unutar sandboxa na nivou operativnog sistema koji ograničava pisanje na direktorije stanja projekta i Kilo",
+3
View File
@@ -1025,6 +1025,9 @@ export const dict = {
"Ingen brugerdefinerede kommandoer konfigureret. Tilføj kommandoer til opencode.json for at se dem her.",
"settings.agentBehaviour.workflows.detail.description": "Beskrivelse",
"settings.agentBehaviour.workflows.detail.template": "Skabelon",
"settings.agentBehaviour.workflows.model": "model",
"settings.agentBehaviour.workflows.variant": "variant",
"settings.agentBehaviour.workflows.modelDescription": "Global modeloverskrivelse",
"settings.sandboxing.enabled.title": "Sandbox",
"settings.sandboxing.enabled.description":
"Kør shell-kommandoer for agenten i en sandbox på operativsystemniveau, der begrænser skrivning til projekt- og Kilo-tilstandsmapperne",
@@ -1050,6 +1050,9 @@ export const dict = {
"Keine benutzerdefinierten Befehle konfiguriert. Fügen Sie Befehle zu opencode.json hinzu, um sie hier zu sehen.",
"settings.agentBehaviour.workflows.detail.description": "Beschreibung",
"settings.agentBehaviour.workflows.detail.template": "Vorlage",
"settings.agentBehaviour.workflows.model": "Modell",
"settings.agentBehaviour.workflows.variant": "Variante",
"settings.agentBehaviour.workflows.modelDescription": "Globale Modellüberschreibung",
"settings.sandboxing.enabled.title": "Sandbox",
"settings.sandboxing.enabled.description":
"Shell-Befehle des Agenten in einer Sandbox auf Betriebssystemebene ausführen, die Schreibvorgänge auf die Projekt- und Kilo-Statusverzeichnisse beschränkt",
@@ -963,6 +963,9 @@ export const dict = {
"No custom commands configured. Add commands to your opencode.json to see them here.",
"settings.agentBehaviour.workflows.detail.description": "Description",
"settings.agentBehaviour.workflows.detail.template": "Template",
"settings.agentBehaviour.workflows.model": "model",
"settings.agentBehaviour.workflows.variant": "variant",
"settings.agentBehaviour.workflows.modelDescription": "Global model override",
"settings.agentBehaviour.createMode": "Create New Mode",
"settings.agentBehaviour.createMode.name": "Name",
+3
View File
@@ -1040,6 +1040,9 @@ export const dict = {
"No hay comandos personalizados configurados. Añada comandos a opencode.json para verlos aquí.",
"settings.agentBehaviour.workflows.detail.description": "Descripción",
"settings.agentBehaviour.workflows.detail.template": "Plantilla",
"settings.agentBehaviour.workflows.model": "modelo",
"settings.agentBehaviour.workflows.variant": "variante",
"settings.agentBehaviour.workflows.modelDescription": "Anulación global del modelo",
"settings.sandboxing.enabled.title": "Sandbox",
"settings.sandboxing.enabled.description":
"Ejecutar los comandos de shell del agente dentro de un sandbox a nivel de sistema operativo que restringe las escrituras a los directorios de estado del proyecto y de Kilo",
+3
View File
@@ -971,6 +971,9 @@ export const dict = {
"هیچ دستور سفارشی پیکربندی نشده است. دستورات را به opencode.json خود اضافه کنید تا اینجا نمایش داده شوند.",
"settings.agentBehaviour.workflows.detail.description": "توضیحات",
"settings.agentBehaviour.workflows.detail.template": "قالب",
"settings.agentBehaviour.workflows.model": "مدل",
"settings.agentBehaviour.workflows.variant": "گونه",
"settings.agentBehaviour.workflows.modelDescription": "بازنویسی مدل سراسری",
"settings.agentBehaviour.createMode": "ایجاد حالت جدید",
"settings.agentBehaviour.createMode.name": "نام",
+3
View File
@@ -1053,6 +1053,9 @@ export const dict = {
"Aucune commande personnalisée configurée. Ajoutez des commandes à opencode.json pour les voir ici.",
"settings.agentBehaviour.workflows.detail.description": "Description",
"settings.agentBehaviour.workflows.detail.template": "Modèle",
"settings.agentBehaviour.workflows.model": "modèle",
"settings.agentBehaviour.workflows.variant": "variante",
"settings.agentBehaviour.workflows.modelDescription": "Remplacement global du modèle",
"settings.sandboxing.enabled.title": "Sandbox",
"settings.sandboxing.enabled.description":
"Exécuter les commandes shell de l'agent dans un sandbox au niveau du système d'exploitation qui restreint les écritures aux répertoires d'état du projet et de Kilo",
+3
View File
@@ -850,6 +850,9 @@ export const dict = {
"Nessun comando personalizzato configurato. Aggiungi comandi a opencode.json per vederli qui.",
"settings.agentBehaviour.workflows.detail.description": "Descrizione",
"settings.agentBehaviour.workflows.detail.template": "Template",
"settings.agentBehaviour.workflows.model": "modello",
"settings.agentBehaviour.workflows.variant": "variante",
"settings.agentBehaviour.workflows.modelDescription": "Override globale del modello",
"settings.agentBehaviour.createMode": "Crea nuova modalità",
"settings.agentBehaviour.createMode.name": "Nome",
"settings.agentBehaviour.createMode.name.placeholder": "es. reviewer",
+3
View File
@@ -1019,6 +1019,9 @@ export const dict = {
"カスタムコマンドが設定されていません。opencode.json にコマンドを追加するとここに表示されます。",
"settings.agentBehaviour.workflows.detail.description": "説明",
"settings.agentBehaviour.workflows.detail.template": "テンプレート",
"settings.agentBehaviour.workflows.model": "モデル",
"settings.agentBehaviour.workflows.variant": "バリアント",
"settings.agentBehaviour.workflows.modelDescription": "グローバルモデルの上書き",
"settings.sandboxing.enabled.title": "サンドボックス",
"settings.sandboxing.enabled.description":
"エージェントのシェルコマンドを、プロジェクトおよびKiloの状態ディレクトリへの書き込みを制限するOSレベルのサンドボックス内で実行",
+3
View File
@@ -1013,6 +1013,9 @@ export const dict = {
"구성된 사용자 정의 명령이 없습니다. opencode.json에 명령을 추가하면 여기에 표시됩니다.",
"settings.agentBehaviour.workflows.detail.description": "설명",
"settings.agentBehaviour.workflows.detail.template": "템플릿",
"settings.agentBehaviour.workflows.model": "모델",
"settings.agentBehaviour.workflows.variant": "변형",
"settings.agentBehaviour.workflows.modelDescription": "전역 모델 재정의",
"settings.sandboxing.enabled.title": "샌드박스",
"settings.sandboxing.enabled.description":
"에이전트 셸 명령을 프로젝트 및 Kilo 상태 디렉터리에 대한 쓰기를 제한하는 OS 수준의 샌드박스 내에서 실행",
+3
View File
@@ -998,6 +998,9 @@ export const dict = {
"Geen aangepaste commando's geconfigureerd. Voeg commando's toe aan opencode.json om ze hier te zien.",
"settings.agentBehaviour.workflows.detail.description": "Beschrijving",
"settings.agentBehaviour.workflows.detail.template": "Sjabloon",
"settings.agentBehaviour.workflows.model": "model",
"settings.agentBehaviour.workflows.variant": "variant",
"settings.agentBehaviour.workflows.modelDescription": "Globale modeloverride",
"settings.autoApprove.description":
"Definieer hoe tools mogen worden uitgevoerd. De meeste tools staan standaard op Toestaan. doom_loop en external_directory staan standaard op Vragen.",
+3
View File
@@ -1025,6 +1025,9 @@ export const dict = {
"Ingen egendefinerte kommandoer konfigurert. Legg til kommandoer i opencode.json for å se dem her.",
"settings.agentBehaviour.workflows.detail.description": "Beskrivelse",
"settings.agentBehaviour.workflows.detail.template": "Mal",
"settings.agentBehaviour.workflows.model": "modell",
"settings.agentBehaviour.workflows.variant": "variant",
"settings.agentBehaviour.workflows.modelDescription": "Global modelloverstyring",
"settings.sandboxing.enabled.title": "Sandbox",
"settings.sandboxing.enabled.description":
"Kjør shell-kommandoer for agenten i en sandbox på operativsystemnivå som begrenser skriving til prosjekt- og Kilo-tilstandsmapper",
+3
View File
@@ -1026,6 +1026,9 @@ export const dict = {
"Brak skonfigurowanych niestandardowych komend. Dodaj komendy do opencode.json, aby je tu zobaczyć.",
"settings.agentBehaviour.workflows.detail.description": "Opis",
"settings.agentBehaviour.workflows.detail.template": "Szablon",
"settings.agentBehaviour.workflows.model": "model",
"settings.agentBehaviour.workflows.variant": "wariant",
"settings.agentBehaviour.workflows.modelDescription": "Globalne nadpisanie modelu",
"settings.sandboxing.enabled.title": "Sandbox",
"settings.sandboxing.enabled.description":
"Uruchamiaj polecenia shell agenta w sandboxie na poziomie systemu operacyjnego, który ogranicza zapisy do katalogów stanu projektu i Kilo",
+3
View File
@@ -1023,6 +1023,9 @@ export const dict = {
"Пользовательские команды не настроены. Добавьте команды в opencode.json, чтобы увидеть их здесь.",
"settings.agentBehaviour.workflows.detail.description": "Описание",
"settings.agentBehaviour.workflows.detail.template": "Шаблон",
"settings.agentBehaviour.workflows.model": "модель",
"settings.agentBehaviour.workflows.variant": "вариант",
"settings.agentBehaviour.workflows.modelDescription": "Глобальное переопределение модели",
"settings.sandboxing.enabled.title": "Песочница",
"settings.sandboxing.enabled.description":
"Выполнять команды оболочки агента в песочнице на уровне ОС, которая ограничивает запись в каталоги состояния проекта и Kilo",
+3
View File
@@ -1010,6 +1010,9 @@ export const dict = {
"ไม่มีคำสั่งแบบกำหนดเองที่กำหนดค่าไว้ เพิ่มคำสั่งใน opencode.json เพื่อดูที่นี่",
"settings.agentBehaviour.workflows.detail.description": "คำอธิบาย",
"settings.agentBehaviour.workflows.detail.template": "เทมเพลต",
"settings.agentBehaviour.workflows.model": "โมเดล",
"settings.agentBehaviour.workflows.variant": "รูปแบบ",
"settings.agentBehaviour.workflows.modelDescription": "การแทนที่โมเดลส่วนกลาง",
"settings.sandboxing.enabled.title": "Sandbox",
"settings.sandboxing.enabled.description":
"เรียกใช้คำสั่ง shell ของ agent ใน sandbox ระดับระบบปฏิบัติการที่จำกัดการเขียนไปยังโฟลเดอร์สถานะของโปรเจ็กต์และ Kilo",
+3
View File
@@ -986,6 +986,9 @@ export const dict = {
"Yapılandırılmış özel komut yok. Burada görmek için opencode.json dosyasına komutlar ekleyin.",
"settings.agentBehaviour.workflows.detail.description": "Açıklama",
"settings.agentBehaviour.workflows.detail.template": "Şablon",
"settings.agentBehaviour.workflows.model": "model",
"settings.agentBehaviour.workflows.variant": "varyant",
"settings.agentBehaviour.workflows.modelDescription": "Global model geçersiz kılması",
"settings.autoApprove.description":
"Araçların nasıl çalıştırılacağını tanımlayın. Çoğu araç varsayılan olarak İzin Ver'dir. doom_loop ve external_directory varsayılan olarak Sor'dur.",
+3
View File
@@ -986,6 +986,9 @@ export const dict = {
"Власних команд не налаштовано. Додайте команди до opencode.json, щоб вони з'явилися тут.",
"settings.agentBehaviour.workflows.detail.description": "Опис",
"settings.agentBehaviour.workflows.detail.template": "Шаблон",
"settings.agentBehaviour.workflows.model": "модель",
"settings.agentBehaviour.workflows.variant": "варіант",
"settings.agentBehaviour.workflows.modelDescription": "Глобальне перевизначення моделі",
"settings.autoApprove.description":
"Визначте, як виконуються інструменти. Більшість інструментів за замовчуванням — Дозволити. doom_loop та external_directory за замовчуванням — Запитувати.",
+3
View File
@@ -977,6 +977,9 @@ export const dict = {
"settings.agentBehaviour.workflows.empty": "未配置自定义命令。将命令添加到 opencode.json 即可在此处看到。",
"settings.agentBehaviour.workflows.detail.description": "描述",
"settings.agentBehaviour.workflows.detail.template": "模板",
"settings.agentBehaviour.workflows.model": "模型",
"settings.agentBehaviour.workflows.variant": "变体",
"settings.agentBehaviour.workflows.modelDescription": "全局模型覆盖",
"settings.sandboxing.enabled.title": "沙盒",
"settings.sandboxing.enabled.description":
"在操作系统级沙盒中运行代理 shell 命令,将写入限制在项目和 Kilo 状态目录内",
+3
View File
@@ -939,6 +939,9 @@ export const dict = {
"settings.agentBehaviour.workflows.empty": "未設定自訂命令。將命令新增至 opencode.json 即可在此處看到。",
"settings.agentBehaviour.workflows.detail.description": "描述",
"settings.agentBehaviour.workflows.detail.template": "範本",
"settings.agentBehaviour.workflows.model": "模型",
"settings.agentBehaviour.workflows.variant": "變體",
"settings.agentBehaviour.workflows.modelDescription": "全域模型覆寫",
"settings.sandboxing.enabled.title": "沙盒",
"settings.sandboxing.enabled.description":
"在作業系統層級沙盒中執行代理 shell 指令,將寫入限制在專案和 Kilo 狀態目錄內",
@@ -345,6 +345,7 @@ const ConfigWrapper: ParentComponent<{
const value = {
config: createMemo(() => cfg()),
globalConfig: createMemo(() => (scoped ? global() : cfg())),
globalDraft: () => ({}),
projectConfig: createMemo(() => (scoped ? project() : cfg())),
collections: () => ({}),
settings,
@@ -25,10 +25,12 @@ export interface ConfigCollectionEntry {
export type ConfigCollections = Record<string, ConfigCollectionEntry[]>
export interface CommandConfig {
template: string
template?: string
description?: string
agent?: string
model?: string
model?: string | null
variant?: string | null
subtask?: boolean
}
export interface SkillsConfig {
+34 -13
View File
@@ -8,6 +8,7 @@ import { Config } from "@/config/config"
import { MCP } from "../mcp"
import { Skill } from "../skill"
import { legacyReviewCommand, reviewCommand } from "@/kilocode/review/command" // kilocode_change
import { apply as applyOverride, type Override } from "@/kilocode/command/override" // kilocode_change
import { EventV2 } from "@opencode-ai/core/event"
import PROMPT_INITIALIZE from "./template/initialize.txt"
@@ -32,6 +33,7 @@ export const Info = Schema.Struct({
description: Schema.optional(Schema.String),
agent: Schema.optional(Schema.String),
model: Schema.optional(Schema.String),
variant: Schema.optional(Schema.String), // kilocode_change
source: Schema.optional(Schema.Literals(["command", "mcp", "skill"])),
trusted: Schema.optional(Schema.Boolean), // kilocode_change - skill-sourced templates only run `!`cmd`` shell when trusted
// Some command templates are lazy promises from MCP prompt resolution.
@@ -114,20 +116,12 @@ export const layer = Layer.effect(
commands["local-review-uncommitted"] = legacyReviewCommand("local-review-uncommitted")!
// kilocode_change end
// kilocode_change start - defer partial overrides until all command sources are registered
const overrides: Array<{ name: string; command: Override }> = []
for (const [name, command] of Object.entries(cfg.command ?? {})) {
commands[name] = {
name,
agent: command.agent,
model: command.model,
description: command.description,
source: "command",
get template() {
return command.template
},
subtask: command.subtask,
hints: hints(command.template),
}
if (!applyOverride(commands, name, command, hints)) overrides.push({ name, command }) // kilocode_change
}
// kilocode_change end
for (const [name, prompt] of Object.entries(yield* mcp.prompts())) {
commands[name] = {
@@ -163,6 +157,31 @@ export const layer = Layer.effect(
commands[item.name] = fromSkill(item) // kilocode_change
}
// kilocode_change start - apply deferred overrides to their registered source
for (const item of overrides) {
const skillTarget = skillName(item.name)
if (skillTarget) {
const found = yield* skill.get(skillTarget)
if (found) {
if (commands[skillTarget]?.source !== "skill") {
commands[item.name] = fromSkill(found)
applyOverride(commands, item.name, item.command, hints) // kilocode_change
} else {
applyOverride(commands, skillTarget, item.command, hints) // kilocode_change
}
}
continue
}
const mcpTarget = mcpName(item.name)
if (mcpTarget) {
if (commands[mcpTarget]?.source !== "mcp") continue
applyOverride(commands, mcpTarget, item.command, hints) // kilocode_change
continue
}
applyOverride(commands, item.name, item.command, hints) // kilocode_change
}
// kilocode_change end
return {
commands,
}
@@ -180,6 +199,8 @@ export const layer = Layer.effect(
// kilocode_change start
const target = skillName(name)
if (target) {
const exact = s.commands[target]
if (exact?.source === "skill") return exact
const item = yield* skill.get(target)
if (item) return fromSkill(item)
return undefined
@@ -201,7 +222,7 @@ export const layer = Layer.effect(
const result = Object.values(s.commands)
const names = new Set(result.map((item) => item.name))
for (const item of yield* skill.all()) {
if (s.commands[item.name]?.source === "skill") continue
if (s.commands[item.name]?.source === "skill" || s.commands[`${item.name}:skill`]?.source === "skill") continue
if (names.has(item.name)) result.push(fromSkill(item))
}
return result
@@ -0,0 +1,53 @@
// kilocode_change - new file
type Existing = {
name: string
description?: string
agent?: string
model?: string
variant?: string
source?: "command" | "mcp" | "skill"
trusted?: boolean
template: string | Promise<string>
subtask?: boolean
hints: readonly string[]
}
export type Override = {
template?: string
description?: string
agent?: string
model?: string
variant?: string
subtask?: boolean
}
type Hints = (template: string) => string[]
export function apply(commands: Record<string, Existing>, name: string, command: Override, hints: Hints) {
const existing = commands[name]
if (command.template === undefined) {
if (!existing) return false
if (command.description !== undefined) existing.description = command.description
if (command.agent !== undefined) existing.agent = command.agent
if (command.model !== undefined) existing.model = command.model
if (command.variant !== undefined) existing.variant = command.variant
if (command.subtask !== undefined) existing.subtask = command.subtask
return true
}
const template = command.template
commands[name] = {
name,
agent: command.agent,
model: command.model,
variant: command.variant,
description: command.description,
source: "command",
get template() {
return template
},
subtask: command.subtask,
hints: hints(template),
}
return true
}
@@ -0,0 +1,34 @@
// kilocode_change - new file
import type { Agent } from "@/agent/agent"
import type { Command } from "@/command"
export function resolve(input: {
command: Pick<Command.Info, "model" | "agent" | "variant">
agent: Pick<Agent.Info, "model" | "variant">
model: { providerID: string; modelID: string }
selected: { variants?: Record<string, unknown> }
input?: string
}) {
if (input.command.variant && input.selected.variants?.[input.command.variant]) return input.command.variant
if (
input.agent.model &&
input.agent.model.providerID === input.model.providerID &&
input.agent.model.modelID === input.model.modelID &&
input.agent.variant &&
input.selected.variants?.[input.agent.variant]
) {
return input.agent.variant
}
if (
!input.command.model &&
(!input.command.agent || !input.agent.model) &&
input.input &&
input.selected.variants?.[input.input]
) {
return input.input
}
return undefined
}
@@ -100,6 +100,7 @@ export namespace KiloTask {
type Model = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
type Saved = Model & { variant?: string }
type Choice = { model: Model; variant?: string; sticky?: boolean; direct?: boolean }
type Workflow = { model: Model; variant?: string }
function key(model: Model) {
return `${model.providerID}/${model.modelID}`
@@ -141,12 +142,14 @@ export namespace KiloTask {
config: Pick<Config.Info, "subagent_model" | "subagent_variant" | "subagent_variant_overrides">
parent: Model
variant?: string
workflow?: Workflow
provider: Provider.Interface
}) {
const state = yield* saved(input.name)
const cfg = parse(input.config.subagent_model)
const override = (model: Model) => input.config.subagent_variant_overrides?.[key(model)] ?? undefined
const choices: Array<Choice | undefined> = [
input.workflow ? { ...input.workflow, direct: true } : undefined,
state
? {
model: { providerID: state.providerID, modelID: state.modelID },
@@ -197,4 +200,20 @@ export namespace KiloTask {
const variant = full?.variants?.[value] ? value : input.variant
return { model: input.parent, variant }
})
export function workflow(value: unknown): Workflow | undefined {
if (!value || typeof value !== "object") return undefined
const item = (value as { workflow?: unknown }).workflow
if (!item || typeof item !== "object") return undefined
const model = (item as { model?: unknown }).model
if (!model || typeof model !== "object") return undefined
const providerID = (model as { providerID?: unknown }).providerID
const modelID = (model as { modelID?: unknown }).modelID
if (typeof providerID !== "string" || typeof modelID !== "string") return undefined
const variant = (item as { variant?: unknown }).variant
return {
model: { providerID: ProviderV2.ID.make(providerID), modelID: ModelV2.ID.make(modelID) },
variant: typeof variant === "string" ? variant : undefined,
}
}
}
+36 -4
View File
@@ -11,6 +11,7 @@ import { KiloSession } from "@/kilocode/session" // kilocode_change
import { SessionTranscript } from "@/kilocode/session/transcript" // kilocode_change
import { KiloCostPropagation } from "@/kilocode/session/cost-propagation" // kilocode_change
import { KiloSessionProcessor } from "@/kilocode/session/processor" // kilocode_change
import * as KiloWorkflowVariant from "@/kilocode/session/workflow-variant" // kilocode_change
import { KiloSessionOverflow } from "@/kilocode/session/overflow" // kilocode_change
import { KiloReference } from "@/kilocode/reference/contains" // kilocode_change
import { KiloReadObject } from "@/kilocode/tool/read-object" // kilocode_change
@@ -350,6 +351,7 @@ export const layer = Layer.effect(
const promptOps = yield* ops()
const { task: taskTool } = yield* registry.named()
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
const taskVariant = task.variant ?? lastUser.model.variant // kilocode_change
const assistantMessage: SessionV1.Assistant = yield* sessions.updateMessage({
id: MessageID.ascending(),
role: "assistant",
@@ -357,7 +359,7 @@ export const layer = Layer.effect(
sessionID,
mode: task.agent,
agent: task.agent,
variant: lastUser.model.variant,
variant: taskVariant, // kilocode_change
path: { cwd: ctx.directory, root: ctx.worktree },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
@@ -404,6 +406,19 @@ export const layer = Layer.effect(
throw error
}
// kilocode_change start - distinguish explicit workflow model selection from the effective subtask model
const workflow = yield* Effect.gen(function* () {
if (!task.command) return undefined
const command = yield* commands.get(task.command)
if (!command) return undefined
if (!command.model && !command.variant && !(command.agent && taskAgent.model)) return undefined
return {
model: task.model ?? { providerID: taskModel.providerID, modelID: taskModel.id },
variant: task.variant,
}
})
// kilocode_change end
let error: Error | undefined
const taskAbort = new AbortController()
// kilocode_change start - shared reader for the child session id written by task.ts ctx.metadata (#6321)
@@ -419,7 +434,13 @@ export const layer = Layer.effect(
sessionID,
abort: taskAbort.signal,
callID: part.callID,
extra: { bypassAgentCheck: true, promptOps },
// kilocode_change start
extra: {
bypassAgentCheck: true,
promptOps,
workflow, // kilocode_change
},
// kilocode_change end
messages: msgs,
metadata: (val: { title?: string; metadata?: Record<string, any> }) =>
Effect.gen(function* () {
@@ -2083,7 +2104,7 @@ export const layer = Layer.effect(
return yield* currentModel(input.sessionID)
})
yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID)
const task = yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID) // kilocode_change
const agent = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo()
if (!agent) {
@@ -2095,6 +2116,16 @@ export const layer = Layer.effect(
}
yield* agents.guardRequirements(agent) // kilocode_change - command agent overrides must satisfy requirements
// kilocode_change start
const variant = KiloWorkflowVariant.resolve({
command: cmd,
agent,
model: taskModel,
selected: task,
input: input.variant,
})
// kilocode_change end
const templateParts = yield* resolvePromptParts(template)
KiloSessionProcessor.markReviewTelemetry(templateParts, input.command) // kilocode_change - mark review commands for completion telemetry
const inputFiles = new Set(
@@ -2112,6 +2143,7 @@ export const layer = Layer.effect(
description: cmd.description ?? "",
command: input.command,
model: { providerID: taskModel.providerID, modelID: taskModel.modelID },
variant, // kilocode_change
prompt: templateParts.find((y) => y.type === "text")?.text ?? "",
},
]
@@ -2136,7 +2168,7 @@ export const layer = Layer.effect(
model: userModel,
agent: userAgent,
parts,
variant: input.variant,
variant: isSubtask ? input.variant : variant, // kilocode_change
snapshotInitialization: input.snapshotInitialization, // kilocode_change
})
yield* events.publish(Command.Event.Executed, {
+1
View File
@@ -213,6 +213,7 @@ export const TaskTool = Tool.define(
providerID: msg.info.providerID,
},
variant: msg.info.variant,
workflow: KiloTask.workflow(ctx.extra), // kilocode_change
provider,
})
const model = selected.model
@@ -0,0 +1,52 @@
import { describe, expect, test } from "bun:test"
import { apply } from "../../../src/kilocode/command/override"
const hints = (template: string) => (template ? [template] : [])
describe("command overrides", () => {
test("updates an existing command without replacing its template", () => {
const commands = {
review: {
name: "review",
template: "Review the changes",
hints: ["existing"],
},
}
apply(commands, "review", { model: "anthropic/claude-sonnet", variant: "high" }, hints)
expect(commands.review).toMatchObject({
template: "Review the changes",
model: "anthropic/claude-sonnet",
variant: "high",
hints: ["existing"],
})
})
test("ignores a partial override for an unknown command", () => {
const commands = {}
expect(apply(commands, "missing", { model: "anthropic/claude-sonnet" }, hints)).toBe(false)
expect(commands).toEqual({})
})
test("preserves a lazy template while applying a partial override", () => {
let reads = 0
const commands = {
review: {
name: "review",
get template() {
reads++
return "Review the changes"
},
hints: ["existing"],
},
}
apply(commands, "review", { variant: "high" }, hints)
expect(reads).toBe(0)
expect((commands.review as { variant?: string }).variant).toBe("high")
})
})
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
describe("workflow model overrides", () => {
test("accepts a command entry containing only model and variant", () => {
const value = Schema.decodeUnknownSync(ConfigV1.Info)({
command: {
review: {
model: "anthropic/claude-sonnet-4-6",
variant: "high",
},
},
})
expect(value.command?.review).toEqual({
model: "anthropic/claude-sonnet-4-6",
variant: "high",
})
})
})
@@ -68,9 +68,21 @@ function session(dir: string) {
const headers = { "Content-Type": "application/json", "x-kilo-directory": dir }
const query = `directory=${encodeURIComponent(dir)}`
const json = async (route: string, init?: RequestInit) => {
const res = await app.request(route, { headers, ...init })
return await res.json()
const json = async (route: string, init?: RequestInit, retry = false) => {
const tries = retry ? 5 : 1
for (let attempt = 0; attempt < tries; attempt++) {
const res = await app.request(route, { headers, ...init })
const body = await res.text()
try {
return JSON.parse(body)
} catch (error) {
if (!retry || !res.ok || attempt === tries - 1) {
throw new Error(`${route} -> ${res.status} ${body.slice(0, 200)}`, { cause: error })
}
await sleep(100)
}
}
throw new Error(`failed to read JSON response from ${route}`)
}
return {
@@ -82,9 +94,9 @@ function session(dir: string) {
body: JSON.stringify({ parts: [{ type: "text", text }] }),
}),
abort: (id: string) => app.request(`/session/${id}/abort`, { method: "POST", headers }),
messages: (id: string) => json(`/session/${id}/message?${query}`) as Promise<Message[]>,
messages: (id: string) => json(`/session/${id}/message?${query}`, undefined, true) as Promise<Message[]>,
status: async (id: string) => {
const all = (await json(`/session/status?${query}`)) as Record<string, { type: string }>
const all = (await json(`/session/status?${query}`, undefined, true)) as Record<string, { type: string }>
return all[id]?.type ?? "idle"
},
}
@@ -5,6 +5,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { Global } from "@opencode-ai/core/global"
import { Server } from "../../../src/server/server"
import { Config } from "../../../src/config/config"
import { ConfigParse } from "../../../src/config/parse"
import { KilocodeConfigOverlay } from "../../../src/kilocode/config/overlay"
import { KilocodeConfigWriter } from "../../../src/kilocode/config/writer"
import { Permission } from "../../../src/permission"
@@ -24,6 +25,7 @@ type Overlay = {
fields: Record<string, { source: string; inherited: boolean; overridden: boolean; value?: unknown }>
collections: Record<string, Array<{ key: string; source: string; inherited: boolean; local?: unknown }>>
targets: { project: Target; global: Target; active: Target }
effective?: Config.Info
}
type Agent = {
name: string
@@ -598,6 +600,58 @@ describe("config overlay routes", () => {
expect(Object.keys(saved.mcp)).toEqual(["local"])
})
test.serial("writes partial global workflow overrides when both JSON and JSONC exist", async () => {
await using global = await tmpdir()
await using project = await tmpdir()
;(Global.Path as { config: string }).config = global.path
await Filesystem.write(path.join(global.path, "kilo.json"), JSON.stringify({ username: "legacy" }))
await Filesystem.write(path.join(global.path, "kilo.jsonc"), "{\n // Keep JSONC as the active target.\n}\n")
await json(
await req(project.path, "/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
scope: "global",
set: { command: { review: { model: "anthropic/claude-sonnet-4-6", variant: "high" } } },
}),
}),
)
const saved = ConfigParse.jsonc(await Bun.file(path.join(global.path, "kilo.jsonc")).text(), "kilo.jsonc")
expect(saved).toMatchObject({
command: { review: { model: "anthropic/claude-sonnet-4-6", variant: "high" } },
})
expect(await Bun.file(path.join(global.path, "kilo.json")).json()).toMatchObject({ username: "legacy" })
})
test.serial("merges workflow overrides with a command body in the lower-precedence global file", async () => {
await using global = await tmpdir()
await using project = await tmpdir()
;(Global.Path as { config: string }).config = global.path
await Filesystem.write(
path.join(global.path, "kilo.json"),
JSON.stringify({ command: { review: { template: "Review the changes" } } }),
)
await Filesystem.write(path.join(global.path, "kilo.jsonc"), '{\n "username": "legacy"\n}\n')
const response = await json<Overlay>(
await req(project.path, "/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
scope: "global",
set: { command: { review: { model: "anthropic/claude-sonnet-4-6", variant: "high" } } },
}),
}),
)
expect(response.effective?.command?.review).toMatchObject({
template: "Review the changes",
model: "anthropic/claude-sonnet-4-6",
variant: "high",
})
})
test.serial("disables inherited mcp server with a minimal local override", async () => {
await using global = await tmpdir()
await using project = await tmpdir()
@@ -644,7 +698,7 @@ describe("config overlay routes", () => {
const edit = body.effective.permission.edit
const after = await json<Agent[]>(await req(project.path, "/agent"))
expect(typeof edit === "string" ? edit : edit["*"]).toBe("ask")
expect(typeof edit === "string" ? edit : edit?.["*"]).toBe("ask")
expect(
Permission.evaluate("edit", "*", after.find((item) => item.name === "code")?.permission ?? []).action,
).toBe("ask")
@@ -678,7 +732,7 @@ describe("config overlay routes", () => {
const edit = body.effective.permission.edit
const after = await json<Agent[]>(await req(project.path, "/agent"))
expect(typeof edit === "string" ? edit : edit["*"]).toBe("ask")
expect(typeof edit === "string" ? edit : edit?.["*"]).toBe("ask")
expect(Permission.evaluate("edit", "*", after.find((item) => item.name === "code")?.permission ?? []).action).toBe(
"ask",
)
@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { resolve } from "../../../src/kilocode/session/workflow-variant"
const selected = { variants: { high: {} } }
const model = { providerID: ProviderV2.ID.make("anthropic"), modelID: ModelV2.ID.make("claude-sonnet") }
describe("workflow variant resolution", () => {
test("prefers the command variant", () => {
expect(
resolve({
command: { model: "anthropic/claude-sonnet", agent: undefined, variant: "high" },
agent: { model: undefined, variant: undefined },
model,
selected,
input: "high",
}),
).toBe("high")
})
test("uses an agent variant only for the agent model", () => {
expect(
resolve({
command: { model: undefined, agent: "reviewer", variant: undefined },
agent: { model, variant: "high" },
model,
selected,
}),
).toBe("high")
expect(
resolve({
command: { model: undefined, agent: "reviewer", variant: undefined },
agent: { model, variant: "high" },
model: { providerID: ProviderV2.ID.make("openai"), modelID: ModelV2.ID.make("gpt-5") },
selected,
}),
).toBeUndefined()
})
test("uses chat variant when an agent does not select a model", () => {
expect(
resolve({
command: { model: undefined, agent: undefined, variant: undefined },
agent: { model: undefined, variant: undefined },
model,
selected,
input: "high",
}),
).toBe("high")
expect(
resolve({
command: { model: "anthropic/claude-sonnet", agent: undefined, variant: undefined },
agent: { model: undefined, variant: undefined },
model,
selected,
input: "high",
}),
).toBeUndefined()
expect(
resolve({
command: { model: undefined, agent: "reviewer", variant: undefined },
agent: { model: undefined, variant: undefined },
model,
selected,
input: "high",
}),
).toBe("high")
})
})
@@ -78,4 +78,90 @@ Skill content.
{ git: true },
),
)
it.live("applies a partial override to a skill command", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Bun.write(
path.join(dir, ".kilo", "skill", "proj", "SKILL.md"),
"---\nname: proj\ndescription: Project skill.\n---\n\nReview files.\n",
),
)
const command = yield* Command.Service
const skill = yield* command.get("proj:skill")
expect(skill?.source).toBe("skill")
expect(skill?.model).toBe("anthropic/claude-sonnet")
expect(skill?.variant).toBe("high")
}),
{
git: true,
config: {
command: {
proj: {
model: "anthropic/claude-sonnet",
variant: "high",
},
},
},
},
),
)
it.live("applies a skill alias override when a command has the same name", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Bun.write(
path.join(dir, ".kilo", "skill", "review", "SKILL.md"),
"---\nname: review\ndescription: Review skill.\n---\n\nReview files.\n",
),
)
const command = yield* Command.Service
const skill = yield* command.get("review:skill")
const list = yield* command.list()
expect(skill?.source).toBe("skill")
expect(skill?.model).toBe("anthropic/claude-sonnet")
expect(list.filter((item) => item.source === "skill" && item.name === "review")).toHaveLength(1)
}),
{
git: true,
config: {
command: {
"review:skill": { model: "anthropic/claude-sonnet" },
review: { template: "Command content." },
},
},
},
),
)
it.live("does not apply a missing MCP alias to a command with the same name", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const command = yield* Command.Service
const plain = yield* command.get("review")
const missing = yield* command.get("review:mcp")
expect(plain?.model).toBeUndefined()
expect(missing).toBeUndefined()
}),
{
git: true,
config: {
command: {
review: { template: "Command content." },
"review:mcp": { model: "anthropic/claude-sonnet" },
},
},
},
),
)
})
+5 -1
View File
@@ -633,6 +633,7 @@ export type SubtaskPart = {
providerID: string
modelID: string
}
variant?: string
command?: string
}
@@ -1555,7 +1556,7 @@ export type Config = {
server?: ServerConfig
command?: {
[key: string]: {
template: string
template?: string
description?: string
agent?: string
model?: string
@@ -2114,6 +2115,7 @@ export type Command = {
description?: string
agent?: string
model?: string
variant?: string
source?: "command" | "mcp" | "skill"
trusted?: boolean
template: string
@@ -2769,6 +2771,7 @@ export type SubtaskPartInput = {
providerID: string
modelID: string
}
variant?: string
command?: string
}
@@ -6204,6 +6207,7 @@ export type CommandV2Info = {
providerID: string
variant?: string
}
variant?: string
subtask?: boolean
}
+12 -1
View File
@@ -26560,6 +26560,9 @@
"required": ["providerID", "modelID"],
"additionalProperties": false
},
"variant": {
"type": "string"
},
"command": {
"type": "string"
}
@@ -29178,7 +29181,6 @@
"type": "boolean"
}
},
"required": ["template"],
"additionalProperties": false
}
},
@@ -30824,6 +30826,9 @@
"model": {
"type": "string"
},
"variant": {
"type": "string"
},
"source": {
"type": "string",
"enum": ["command", "mcp", "skill"]
@@ -32871,6 +32876,9 @@
"required": ["providerID", "modelID"],
"additionalProperties": false
},
"variant": {
"type": "string"
},
"command": {
"type": "string"
}
@@ -44436,6 +44444,9 @@
"required": ["id", "providerID"],
"additionalProperties": false
},
"variant": {
"type": "string"
},
"subtask": {
"type": "boolean"
}