From ce7984fc4247fb2805990ef62e1b1d9f286de9d9 Mon Sep 17 00:00:00 2001 From: Marius Date: Mon, 3 Aug 2026 16:46:07 +0200 Subject: [PATCH] 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 --- .changeset/workflow-model-variants.md | 6 ++ packages/core/src/command.ts | 1 + packages/core/src/config/command.ts | 2 +- packages/core/src/config/plugin/command.ts | 41 +++++---- packages/core/src/v1/config/command.ts | 2 +- packages/core/src/v1/session.ts | 2 + packages/core/test/config/command.test.ts | 35 ++++++++ packages/core/test/pty/pty-session.test.ts | 11 ++- .../settings/agent-behaviour/WorkflowsTab.tsx | 72 +++++++++++++++- .../components/shared/ThinkingSelector.tsx | 4 +- .../webview-ui/src/context/config.tsx | 2 + .../kilo-vscode/webview-ui/src/i18n/ar.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/br.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/bs.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/da.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/de.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/en.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/es.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/fa.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/fr.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/it.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/ja.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/ko.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/nl.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/no.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/pl.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/ru.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/th.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/tr.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/uk.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/zh.ts | 3 + .../kilo-vscode/webview-ui/src/i18n/zht.ts | 3 + .../webview-ui/src/stories/StoryProviders.tsx | 1 + .../webview-ui/src/types/messages/config.ts | 6 +- packages/opencode/src/command/index.ts | 47 +++++++--- .../opencode/src/kilocode/command/override.ts | 53 ++++++++++++ .../src/kilocode/session/workflow-variant.ts | 34 ++++++++ packages/opencode/src/kilocode/tool/task.ts | 19 ++++ packages/opencode/src/session/prompt.ts | 40 ++++++++- packages/opencode/src/tool/task.ts | 1 + .../test/kilocode/command/override.test.ts | 52 +++++++++++ .../config/workflow-model-override.test.ts | 21 +++++ .../test/kilocode/issue-8656-stall.test.ts | 22 +++-- .../kilocode/server/config-overlay.test.ts | 58 ++++++++++++- .../kilocode/session/workflow-variant.test.ts | 73 ++++++++++++++++ .../skill-command-autocomplete.test.ts | 86 +++++++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 6 +- packages/sdk/openapi.json | 13 ++- 48 files changed, 722 insertions(+), 51 deletions(-) create mode 100644 .changeset/workflow-model-variants.md create mode 100644 packages/opencode/src/kilocode/command/override.ts create mode 100644 packages/opencode/src/kilocode/session/workflow-variant.ts create mode 100644 packages/opencode/test/kilocode/command/override.test.ts create mode 100644 packages/opencode/test/kilocode/config/workflow-model-override.test.ts create mode 100644 packages/opencode/test/kilocode/session/workflow-variant.test.ts diff --git a/.changeset/workflow-model-variants.md b/.changeset/workflow-model-variants.md new file mode 100644 index 0000000000..48b090358c --- /dev/null +++ b/.changeset/workflow-model-variants.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": minor +"kilo-code": minor +--- + +Configure a model and reasoning variant for each workflow from Agent Behaviour settings. diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts index f6b8210be1..6b1008217c 100644 --- a/packages/core/src/command.ts +++ b/packages/core/src/command.ts @@ -11,6 +11,7 @@ export class Info extends Schema.Class("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), }) {} diff --git a/packages/core/src/config/command.ts b/packages/core/src/config/command.ts index 394079b1e9..01bdb2120d 100644 --- a/packages/core/src/config/command.ts +++ b/packages/core/src/config/command.ts @@ -3,7 +3,7 @@ export * as ConfigCommand from "./command" import { Schema } from "effect" export class Info extends Schema.Class("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), diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts index 7e71f306e8..973efb83d4 100644 --- a/packages/core/src/config/plugin/command.ts +++ b/packages/core/src/config/plugin/command.ts @@ -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 + }) } }) }), diff --git a/packages/core/src/v1/config/command.ts b/packages/core/src/v1/config/command.ts index 281d530910..e0b578d184 100644 --- a/packages/core/src/v1/config/command.ts +++ b/packages/core/src/v1/config/command.ts @@ -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), diff --git a/packages/core/src/v1/session.ts b/packages/core/src/v1/session.ts index eef2cee8df..cdc9d5a589 100644 --- a/packages/core/src/v1/session.ts +++ b/packages/core/src/v1/session.ts @@ -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> @@ -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> diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index da3bb749b4..f5e4f41af7 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -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"), + }, + }) + }), + ) }) diff --git a/packages/core/test/pty/pty-session.test.ts b/packages/core/test/pty/pty-session.test.ts index 30e223faac..f74abf88a8 100644 --- a/packages/core/test/pty/pty-session.test.ts +++ b/packages/core/test/pty/pty-session.test.ts @@ -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, 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, 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() }), ) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/agent-behaviour/WorkflowsTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/agent-behaviour/WorkflowsTab.tsx index 61d1148af3..afcbba8a83 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/agent-behaviour/WorkflowsTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/agent-behaviour/WorkflowsTab.tsx @@ -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>({}) @@ -16,6 +22,38 @@ const WorkflowsTab: Component = () => { setExpanded((prev) => ({ ...prev, [name]: !prev[name] })) } + const update = (name: string, patch: Partial) => { + 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 (
{/* Description */} @@ -118,6 +156,38 @@ const WorkflowsTab: Component = () => { {cmd.description}
+
e.stopPropagation()} + > + 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")} + /> + 0 || !!variant(cmd, name)}> + 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")}`} + /> + +
diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx index f49c649a9d..ff6cd6f3d4 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx @@ -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 = (props) => { @@ -163,7 +165,7 @@ export const ThinkingSelectorBase: Component = (props open={open()} onOpenChange={onOpen} triggerAs={Button} - triggerProps={{ variant: "ghost", size: "small" }} + triggerProps={{ variant: "ghost", size: "small", "aria-label": props.label }} trigger={ <> {display(props.value)} diff --git a/packages/kilo-vscode/webview-ui/src/context/config.tsx b/packages/kilo-vscode/webview-ui/src/context/config.tsx index 904c5fdcdb..6d2785316b 100644 --- a/packages/kilo-vscode/webview-ui/src/context/config.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/config.tsx @@ -40,6 +40,7 @@ export interface SaveError { interface ConfigContextValue { config: Accessor globalConfig: Accessor + globalDraft: Accessor> projectConfig: Accessor collections: Accessor settings: Accessor> @@ -397,6 +398,7 @@ export const ConfigProvider: ParentComponent = (props) => { const value: ConfigContextValue = { config, globalConfig, + globalDraft, projectConfig, collections, settings, diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 4aca678d7b..6c425940a8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 46e7fd6c13..1a7d9ae17b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 520a51a067..509f026520 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index d21ccf759e..4f1d6c9034 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index a0ea761029..7c1366c726 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 6e38f5ff21..5752f6b2a8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 1caf01a9c0..2b7ed694b0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts index 928069dad2..71b97a2d3f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -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": "نام", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 90d6a0d93c..3fd9956d9d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index 5ff35d8cb1..b8ec52156f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 66fe638da3..fa1eda6f6b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -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レベルのサンドボックス内で実行", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 282acffa52..360ed69cb0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -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 수준의 샌드박스 내에서 실행", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 4ed4b284f2..64f70685b4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -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.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 580f7ff506..103de2edff 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 1a2c0e2641..18830cde9b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index d0651d0e62..5eca69def5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 344acc5c41..c4dbc68bcf 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index c4cdb4d222..0761b27b94 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -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.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 6f82c04106..b4b8c529d2 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -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 за замовчуванням — Запитувати.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index e98113e8d5..041c72d116 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -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 状态目录内", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 9795bca81a..52ecca5fa9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -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 狀態目錄內", diff --git a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx index 6328f8d781..16361cf8f0 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -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, diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts index b59633f748..84a6e9ff5b 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts @@ -25,10 +25,12 @@ export interface ConfigCollectionEntry { export type ConfigCollections = Record export interface CommandConfig { - template: string + template?: string description?: string agent?: string - model?: string + model?: string | null + variant?: string | null + subtask?: boolean } export interface SkillsConfig { diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index c4cffa639b..332b2e5c87 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -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 diff --git a/packages/opencode/src/kilocode/command/override.ts b/packages/opencode/src/kilocode/command/override.ts new file mode 100644 index 0000000000..277a62be9e --- /dev/null +++ b/packages/opencode/src/kilocode/command/override.ts @@ -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 + 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, 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 +} diff --git a/packages/opencode/src/kilocode/session/workflow-variant.ts b/packages/opencode/src/kilocode/session/workflow-variant.ts new file mode 100644 index 0000000000..16e3e6cc5a --- /dev/null +++ b/packages/opencode/src/kilocode/session/workflow-variant.ts @@ -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 + agent: Pick + model: { providerID: string; modelID: string } + selected: { variants?: Record } + 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 +} diff --git a/packages/opencode/src/kilocode/tool/task.ts b/packages/opencode/src/kilocode/tool/task.ts index c73774e431..f5d094df7b 100644 --- a/packages/opencode/src/kilocode/tool/task.ts +++ b/packages/opencode/src/kilocode/tool/task.ts @@ -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 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 = [ + 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, + } + } } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0b4c74fe5f..1928ef8625 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -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 }) => 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, { diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 4981aa5e77..a67cf4e12a 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -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 diff --git a/packages/opencode/test/kilocode/command/override.test.ts b/packages/opencode/test/kilocode/command/override.test.ts new file mode 100644 index 0000000000..2c8806d099 --- /dev/null +++ b/packages/opencode/test/kilocode/command/override.test.ts @@ -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") + }) +}) diff --git a/packages/opencode/test/kilocode/config/workflow-model-override.test.ts b/packages/opencode/test/kilocode/config/workflow-model-override.test.ts new file mode 100644 index 0000000000..6a90db18bd --- /dev/null +++ b/packages/opencode/test/kilocode/config/workflow-model-override.test.ts @@ -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", + }) + }) +}) diff --git a/packages/opencode/test/kilocode/issue-8656-stall.test.ts b/packages/opencode/test/kilocode/issue-8656-stall.test.ts index c6c939e1ec..dbdbf2a46b 100644 --- a/packages/opencode/test/kilocode/issue-8656-stall.test.ts +++ b/packages/opencode/test/kilocode/issue-8656-stall.test.ts @@ -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, + messages: (id: string) => json(`/session/${id}/message?${query}`, undefined, true) as Promise, status: async (id: string) => { - const all = (await json(`/session/status?${query}`)) as Record + const all = (await json(`/session/status?${query}`, undefined, true)) as Record return all[id]?.type ?? "idle" }, } diff --git a/packages/opencode/test/kilocode/server/config-overlay.test.ts b/packages/opencode/test/kilocode/server/config-overlay.test.ts index c67598ba43..5a331d0fce 100644 --- a/packages/opencode/test/kilocode/server/config-overlay.test.ts +++ b/packages/opencode/test/kilocode/server/config-overlay.test.ts @@ -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 collections: Record> 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( + 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(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(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", ) diff --git a/packages/opencode/test/kilocode/session/workflow-variant.test.ts b/packages/opencode/test/kilocode/session/workflow-variant.test.ts new file mode 100644 index 0000000000..d7cb4a4831 --- /dev/null +++ b/packages/opencode/test/kilocode/session/workflow-variant.test.ts @@ -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") + }) +}) diff --git a/packages/opencode/test/kilocode/skill-command-autocomplete.test.ts b/packages/opencode/test/kilocode/skill-command-autocomplete.test.ts index 50ea098b03..7ef70b045a 100644 --- a/packages/opencode/test/kilocode/skill-command-autocomplete.test.ts +++ b/packages/opencode/test/kilocode/skill-command-autocomplete.test.ts @@ -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" }, + }, + }, + }, + ), + ) }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b95780c020..f234bbed58 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -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 } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 6e46bad83b..71ff553fff 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -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" }