From bddce1a49dd78c9673e971cdedd3665758378ac6 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Thu, 6 Aug 2026 12:28:30 +0200 Subject: [PATCH 01/34] fix: infer custom provider reasoning efforts --- .changeset/broad-custom-provider-efforts.md | 6 + .../kilo-vscode/src/shared/custom-provider.ts | 9 +- .../custom-provider-dialog-validate.test.ts | 28 ++ .../tests/unit/custom-provider.test.ts | 29 ++ .../tests/unit/provider-actions-save.test.ts | 21 ++ .../settings/CustomProviderDialog.tsx | 38 +-- .../settings/CustomProviderModelCard.tsx | 306 +----------------- .../settings/CustomProviderValidation.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/ar.ts | 38 --- .../kilo-vscode/webview-ui/src/i18n/br.ts | 39 --- .../kilo-vscode/webview-ui/src/i18n/bs.ts | 39 --- .../kilo-vscode/webview-ui/src/i18n/da.ts | 39 --- .../kilo-vscode/webview-ui/src/i18n/de.ts | 39 --- .../kilo-vscode/webview-ui/src/i18n/en.ts | 39 --- .../kilo-vscode/webview-ui/src/i18n/es.ts | 39 --- .../kilo-vscode/webview-ui/src/i18n/fa.ts | 39 --- .../kilo-vscode/webview-ui/src/i18n/fr.ts | 39 --- .../kilo-vscode/webview-ui/src/i18n/it.ts | 39 --- .../kilo-vscode/webview-ui/src/i18n/ja.ts | 38 --- .../kilo-vscode/webview-ui/src/i18n/ko.ts | 38 --- .../kilo-vscode/webview-ui/src/i18n/nl.ts | 39 --- .../kilo-vscode/webview-ui/src/i18n/no.ts | 39 --- .../kilo-vscode/webview-ui/src/i18n/pl.ts | 39 --- .../kilo-vscode/webview-ui/src/i18n/ru.ts | 39 --- .../kilo-vscode/webview-ui/src/i18n/th.ts | 39 --- .../kilo-vscode/webview-ui/src/i18n/tr.ts | 39 --- .../kilo-vscode/webview-ui/src/i18n/uk.ts | 39 --- .../kilo-vscode/webview-ui/src/i18n/zh.ts | 38 --- .../kilo-vscode/webview-ui/src/i18n/zht.ts | 38 --- .../src/kilocode/provider/provider.ts | 33 ++ packages/opencode/src/provider/provider.ts | 10 +- .../kilocode/custom-provider-variants.test.ts | 46 +++ .../provider-reasoning-options.test.ts | 53 +++ 33 files changed, 230 insertions(+), 1164 deletions(-) create mode 100644 .changeset/broad-custom-provider-efforts.md create mode 100644 packages/opencode/test/kilocode/custom-provider-variants.test.ts diff --git a/.changeset/broad-custom-provider-efforts.md b/.changeset/broad-custom-provider-efforts.md new file mode 100644 index 00000000000..2b275555aa1 --- /dev/null +++ b/.changeset/broad-custom-provider-efforts.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Automatically expose broad reasoning effort options for custom provider models instead of requiring manual variant configuration. diff --git a/packages/kilo-vscode/src/shared/custom-provider.ts b/packages/kilo-vscode/src/shared/custom-provider.ts index 757897d1c16..3ac8ddb917b 100644 --- a/packages/kilo-vscode/src/shared/custom-provider.ts +++ b/packages/kilo-vscode/src/shared/custom-provider.ts @@ -12,14 +12,7 @@ export const EnvSchema = z .trim() .regex(/^[A-Z_][A-Z0-9_]*$/, INVALID_ENV) -const VariantConfigSchema = z.object({ - enable_thinking: z.boolean().optional(), - thinking: z.object({ type: z.enum(["enabled", "disabled", "adaptive"]) }).optional(), - reasoning_split: z.boolean().optional(), - reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]).optional(), - effort: z.enum(["low", "medium", "high", "xhigh", "max"]).optional(), - chat_template_args: z.object({ enable_thinking: z.boolean() }).optional(), -}) +const VariantConfigSchema = z.record(z.string(), z.unknown()) export type VariantConfig = z.infer diff --git a/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts b/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts index be1f69c0504..ff405481c06 100644 --- a/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts +++ b/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts @@ -206,6 +206,34 @@ describe("validateCustomProvider – variant name validation", () => { }) }) + it("preserves opaque variant options after the editor controls are removed", () => { + const form = base() + const raw = { + thinking: { type: "adaptive", display: "summarized" }, + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + customOption: { enabled: true }, + } + form.models[0].reasoning = true + form.models[0].variants = [ + { + name: "high", + raw, + enableThinking: undefined, + thinking: "adaptive", + splitReasoning: undefined, + outputEffort: undefined, + reasoningEffort: undefined, + chatTemplateArgs: undefined, + }, + ] + + const out = validateCustomProvider(args(form)) + expect(out.result).toBeDefined() + const saved = out.result!.config.models["model-1"] as Record + expect(saved.variants).toEqual({ high: raw }) + }) + it("serializes image modality when supportsImages is set", () => { const form = base() form.models[0].supportsImages = true diff --git a/packages/kilo-vscode/tests/unit/custom-provider.test.ts b/packages/kilo-vscode/tests/unit/custom-provider.test.ts index b4869706e43..30b420abb95 100644 --- a/packages/kilo-vscode/tests/unit/custom-provider.test.ts +++ b/packages/kilo-vscode/tests/unit/custom-provider.test.ts @@ -162,6 +162,35 @@ describe("sanitizeCustomProviderConfig", () => { }) }) + it("preserves opaque options on existing variants", () => { + const variant = { + thinking: { type: "adaptive", display: "summarized" }, + reasoningEffort: "max", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + customOption: { enabled: true }, + } + const result = sanitizeCustomProviderConfig({ + name: "Thinking Provider", + options: { baseURL: "https://example.com/v1" }, + models: { + "model-1": { + name: "Model One", + variants: { high: variant }, + }, + }, + }) + + expect(result).toEqual({ + value: { + npm: "@ai-sdk/openai-compatible", + name: "Thinking Provider", + options: { baseURL: "https://example.com/v1" }, + models: { "model-1": { name: "Model One", variants: { high: variant } } }, + }, + }) + }) + it("preserves core custom model modalities", () => { const result = sanitizeCustomProviderConfig({ name: "Media Provider", diff --git a/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts b/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts index a6499bab1c6..8f0e10b60a8 100644 --- a/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts +++ b/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts @@ -206,6 +206,27 @@ describe("saveCustomProvider", () => { expect(calls.set).toEqual([{ providerID: "myprovider", auth: { type: "api", key: "sk-test" } }]) }) + it("preserves opaque existing variant options through the save boundary", async () => { + const variant = { + thinking: { type: "adaptive", display: "summarized" }, + reasoningEffort: "custom", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + customOption: { enabled: true }, + } + const saved = { + ...createSavedProvider(), + models: { "model-1": { name: "Model One", reasoning: true, variants: { high: variant } } }, + } + const existing = { disabled_providers: [], provider: { myprovider: saved } } + const { ctx, calls, setCachedConfig } = createCtx(existing) + + await saveCustomProvider(ctx, "req", "myprovider", saved, undefined, false, null, setCachedConfig) + + const provider = (calls.config[0]?.config.provider as Record).myprovider + expect(provider.models["model-1"].variants.high).toEqual(variant) + }) + // Regression tests for https://github.com/Kilo-Org/kilocode/issues/9186 // // The CLI's config.update endpoint deep-merges its payload with the existing diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx index 1041664e41c..e81910eadf7 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx @@ -89,6 +89,7 @@ function modes(raw: unknown): Modalities { function parseVariant([name, cfg]: [string, Record]): VariantEntry { return { name, + raw: cfg, enableThinking: typeof cfg.enable_thinking === "boolean" ? cfg.enable_thinking : undefined, thinking: typeof cfg.thinking === "object" && cfg.thinking !== null @@ -460,25 +461,6 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => { setErrors("headers", (v) => v.filter((_, i) => i !== index)) } - function addVariant(mi: number) { - const blank: VariantEntry = { - name: "", - enableThinking: undefined, - thinking: undefined, - splitReasoning: undefined, - reasoningEffort: undefined, - outputEffort: undefined, - chatTemplateArgs: undefined, - } - setForm("models", mi, "variants", (v) => [...v, blank]) - setErrors("models", mi, "variants", (v) => [...(v ?? []), {}]) - } - - function removeVariant(mi: number, vi: number) { - setForm("models", mi, "variants", (v) => v.filter((_, i) => i !== vi)) - setErrors("models", mi, "variants", (v) => (v ?? []).filter((_, i) => i !== vi)) - } - function validate() { const output = validateCustomProvider({ form, @@ -673,7 +655,6 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => { {(m, i) => ( 1} @@ -682,23 +663,6 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => { onChangeReasoning={(v) => setForm("models", i(), "reasoning", v)} onChangeSupportsImages={(v) => setForm("models", i(), "supportsImages", v)} onRemove={() => removeModel(i())} - onAddVariant={() => addVariant(i())} - onRemoveVariant={(vi) => removeVariant(i(), vi)} - onChangeVariantName={(vi, val) => setForm("models", i(), "variants", vi, "name", val)} - onChangeVariantEnableThinking={(vi, val) => - setForm("models", i(), "variants", vi, "enableThinking", val) - } - onChangeVariantThinking={(vi, val) => setForm("models", i(), "variants", vi, "thinking", val)} - onChangeVariantSplitReasoning={(vi, val) => - setForm("models", i(), "variants", vi, "splitReasoning", val) - } - onChangeVariantReasoningEffort={(vi, val) => - setForm("models", i(), "variants", vi, "reasoningEffort", val) - } - onChangeVariantOutputEffort={(vi, val) => setForm("models", i(), "variants", vi, "outputEffort", val)} - onChangeVariantChatTemplateArgs={(vi, val) => - setForm("models", i(), "variants", vi, "chatTemplateArgs", val) - } /> )} diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderModelCard.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderModelCard.tsx index d1b4ea13f3d..fa440816f31 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderModelCard.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderModelCard.tsx @@ -1,8 +1,5 @@ -import { Button } from "@kilocode/kilo-ui/button" import { IconButton } from "@kilocode/kilo-ui/icon-button" -import { Select } from "@kilocode/kilo-ui/select" import { TextField } from "@kilocode/kilo-ui/text-field" -import { For, Show } from "solid-js" import { useLanguage } from "../../context/language" export type Translator = ReturnType["t"] @@ -23,6 +20,7 @@ export type Modalities = { export type VariantEntry = { name: string + raw?: Record enableThinking: EnableThinkingValue thinking: ThinkingTypeValue splitReasoning: SplitReasoningValue @@ -40,265 +38,9 @@ export type ModelEntry = { variants: VariantEntry[] } -type SelectOption = { value: T; labelKey: string } - -const ENABLE_THINKING_OPTIONS: SelectOption[] = [ - { value: undefined, labelKey: "provider.custom.models.variants.option.unset" }, - { value: true, labelKey: "provider.custom.models.variants.enableThinking.true" }, - { value: false, labelKey: "provider.custom.models.variants.enableThinking.false" }, -] - -const THINKING_OPTIONS: SelectOption[] = [ - { value: undefined, labelKey: "provider.custom.models.variants.option.unset" }, - { value: "enabled", labelKey: "provider.custom.models.variants.thinking.enabled" }, - { value: "disabled", labelKey: "provider.custom.models.variants.thinking.disabled" }, - { value: "adaptive", labelKey: "provider.custom.models.variants.thinking.adaptive" }, -] - -const SPLIT_REASONING_OPTIONS: SelectOption[] = [ - { value: undefined, labelKey: "provider.custom.models.variants.option.unset" }, - { value: true, labelKey: "provider.custom.models.variants.splitReasoning.true" }, - { value: false, labelKey: "provider.custom.models.variants.splitReasoning.false" }, -] - -const CHAT_TEMPLATE_ARGS_OPTIONS: SelectOption[] = [ - { value: undefined, labelKey: "provider.custom.models.variants.option.unset" }, - { value: true, labelKey: "provider.custom.models.variants.chatTemplateArgs.true" }, - { value: false, labelKey: "provider.custom.models.variants.chatTemplateArgs.false" }, -] - -const REASONING_EFFORT_OPTIONS: SelectOption[] = [ - { value: undefined, labelKey: "provider.custom.models.variants.option.unset" }, - { value: "none", labelKey: "provider.custom.models.variants.reasoningEffort.none" }, - { value: "minimal", labelKey: "provider.custom.models.variants.reasoningEffort.minimal" }, - { value: "low", labelKey: "provider.custom.models.variants.reasoningEffort.low" }, - { value: "medium", labelKey: "provider.custom.models.variants.reasoningEffort.medium" }, - { value: "high", labelKey: "provider.custom.models.variants.reasoningEffort.high" }, - { value: "xhigh", labelKey: "provider.custom.models.variants.reasoningEffort.xhigh" }, -] - -const OUTPUT_EFFORT_OPTIONS: SelectOption[] = [ - { value: undefined, labelKey: "provider.custom.models.variants.option.unset" }, - { value: "low", labelKey: "provider.custom.models.variants.outputEffort.low" }, - { value: "medium", labelKey: "provider.custom.models.variants.outputEffort.medium" }, - { value: "high", labelKey: "provider.custom.models.variants.outputEffort.high" }, - { value: "xhigh", labelKey: "provider.custom.models.variants.outputEffort.xhigh" }, - { value: "max", labelKey: "provider.custom.models.variants.outputEffort.max" }, -] - -type VariantRowProps = { - v: VariantEntry - vi: () => number - isFirst: () => boolean - error: { name?: string } | undefined - t: Translator - onChangeName: (val: string) => void - onChangeEnableThinking: (val: EnableThinkingValue) => void - onChangeThinking: (val: ThinkingTypeValue) => void - onChangeSplitReasoning: (val: SplitReasoningValue) => void - onChangeReasoningEffort: (val: ReasoningEffortValue) => void - onChangeOutputEffort: (val: OutputEffortValue) => void - onChangeChatTemplateArgs: (val: ChatTemplateArgsValue) => void - onRemove: () => void -} - -function VariantRow(props: VariantRowProps) { - return ( -
- -
- -
-
- -
-
- - o.value === props.v.thinking)} - value={(o) => String(o.value)} - label={(o) => props.t(o.labelKey)} - onSelect={(o) => props.onChangeThinking(o?.value)} - placeholder={props.t("provider.custom.models.variants.thinking.placeholder")} - variant="secondary" - size="small" - triggerVariant="settings" - /> -
-
- - o.value === props.v.reasoningEffort)} - value={(o) => String(o.value)} - label={(o) => props.t(o.labelKey)} - onSelect={(o) => props.onChangeReasoningEffort(o?.value)} - placeholder={props.t("provider.custom.models.variants.reasoningEffort.placeholder")} - variant="secondary" - size="small" - triggerVariant="settings" - /> -
-
- - o.value === props.v.chatTemplateArgs)} - value={(o) => String(o.value)} - label={(o) => props.t(o.labelKey)} - onSelect={(o) => props.onChangeChatTemplateArgs(o?.value)} - placeholder={props.t("provider.custom.models.variants.chatTemplateArgs.placeholder")} - variant="secondary" - size="small" - triggerVariant="settings" - /> -
- -
-
- ) -} - type ModelCardProps = { m: ModelEntry - i: () => number - errors: { id?: string; name?: string; variants?: Array<{ name?: string }> } + errors: { id?: string; name?: string } t: Translator canRemove: boolean onChangeId: (val: string) => void @@ -306,15 +48,6 @@ type ModelCardProps = { onChangeReasoning: (val: boolean) => void onChangeSupportsImages: (val: boolean) => void onRemove: () => void - onAddVariant: () => void - onRemoveVariant: (vi: number) => void - onChangeVariantName: (vi: number, val: string) => void - onChangeVariantEnableThinking: (vi: number, val: EnableThinkingValue) => void - onChangeVariantThinking: (vi: number, val: ThinkingTypeValue) => void - onChangeVariantSplitReasoning: (vi: number, val: SplitReasoningValue) => void - onChangeVariantReasoningEffort: (vi: number, val: ReasoningEffortValue) => void - onChangeVariantOutputEffort: (vi: number, val: OutputEffortValue) => void - onChangeVariantChatTemplateArgs: (vi: number, val: ChatTemplateArgsValue) => void } export function ModelCard(props: ModelCardProps) { @@ -398,41 +131,6 @@ export function ModelCard(props: ModelCardProps) { /> {props.t("provider.custom.models.modalities.image")} - - {/* Variants — only available when reasoning is enabled */} - - 0}> -
- - - {(v, vi) => ( - vi() === 0} - error={props.errors.variants?.[vi()]} - t={props.t} - onChangeName={(val) => props.onChangeVariantName(vi(), val)} - onChangeEnableThinking={(val) => props.onChangeVariantEnableThinking(vi(), val)} - onChangeThinking={(val) => props.onChangeVariantThinking(vi(), val)} - onChangeSplitReasoning={(val) => props.onChangeVariantSplitReasoning(vi(), val)} - onChangeReasoningEffort={(val) => props.onChangeVariantReasoningEffort(vi(), val)} - onChangeOutputEffort={(val) => props.onChangeVariantOutputEffort(vi(), val)} - onChangeChatTemplateArgs={(val) => props.onChangeVariantChatTemplateArgs(vi(), val)} - onRemove={() => props.onRemoveVariant(vi())} - /> - )} - -
-
- -
) } diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderValidation.ts b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderValidation.ts index 2331d295435..1032e6cf91d 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderValidation.ts +++ b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderValidation.ts @@ -105,6 +105,7 @@ function checkProviderID(id: string, editing: boolean, disabled: string[], exist } function serializeVariant(v: VariantEntry): [string, Record] { + if (v.raw) return [v.name.trim(), v.raw] const cfg: Record = {} if (v.enableThinking !== undefined) cfg.enable_thinking = v.enableThinking if (v.thinking !== undefined) cfg.thinking = { type: v.thinking } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 6c425940a8f..be67beb1093 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -433,44 +433,6 @@ export const dict = { "provider.custom.models.name.placeholder": "الاسم المعروض", "provider.custom.models.reasoning.label": "الاستدلال", "provider.custom.models.modalities.image": "صورة", - "provider.custom.models.variants.label": "المتغيرات", - "provider.custom.models.variants.add": "إضافة متغير", - "provider.custom.models.variants.remove": "إزالة المتغير", - "provider.custom.models.variants.name.label": "الاسم", - "provider.custom.models.variants.name.placeholder": "على سبيل المثال: thinking", - "provider.custom.models.variants.option.unset": "(غير محدد)", - "provider.custom.models.variants.enableThinking.label": "تمكين التفكير (مثل Alibaba)", - "provider.custom.models.variants.enableThinking.placeholder": "enable_thinking", - "provider.custom.models.variants.enableThinking.true": "true", - "provider.custom.models.variants.enableThinking.false": "false", - "provider.custom.models.variants.thinking.label": "نوع التفكير (مثل Z.ai)", - "provider.custom.models.variants.thinking.placeholder": "thinking", - "provider.custom.models.variants.thinking.enabled": "enabled", - "provider.custom.models.variants.thinking.disabled": "disabled", - "provider.custom.models.variants.thinking.adaptive": "adaptive", - "provider.custom.models.variants.splitReasoning.label": "Split reasoning (required for e.g. MiniMax)", - "provider.custom.models.variants.splitReasoning.placeholder": "reasoning_split", - "provider.custom.models.variants.splitReasoning.true": "true", - "provider.custom.models.variants.splitReasoning.false": "false", - "provider.custom.models.variants.chatTemplateArgs.label": "تفعيل التفكير عبر وسائط قالب الدردشة (مثل Hugging Face)", - "provider.custom.models.variants.chatTemplateArgs.placeholder": "chat_template_args", - "provider.custom.models.variants.chatTemplateArgs.true": "true", - "provider.custom.models.variants.chatTemplateArgs.false": "false", - "provider.custom.models.variants.reasoningEffort.label": "جهد الاستدلال", - "provider.custom.models.variants.reasoningEffort.placeholder": "reasoningEffort", - "provider.custom.models.variants.reasoningEffort.none": "none", - "provider.custom.models.variants.reasoningEffort.minimal": "minimal", - "provider.custom.models.variants.reasoningEffort.low": "low", - "provider.custom.models.variants.reasoningEffort.medium": "medium", - "provider.custom.models.variants.reasoningEffort.high": "high", - "provider.custom.models.variants.reasoningEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.label": "Output effort (e.g. Anthropic)", - "provider.custom.models.variants.outputEffort.placeholder": "effort", - "provider.custom.models.variants.outputEffort.low": "low", - "provider.custom.models.variants.outputEffort.medium": "medium", - "provider.custom.models.variants.outputEffort.high": "high", - "provider.custom.models.variants.outputEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.max": "max", "provider.custom.models.remove": "إزالة النموذج", "provider.custom.models.add": "إضافة نموذج", "provider.custom.models.fetch.authError": "فشلت المصادقة. تحقق من مفتاح API أعلاه وحاول مرة أخرى.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 1a7d9ae17ba..3a066b96b81 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -444,45 +444,6 @@ export const dict = { "provider.custom.models.name.placeholder": "Nome de Exibição", "provider.custom.models.reasoning.label": "Raciocínio", "provider.custom.models.modalities.image": "Imagem", - "provider.custom.models.variants.label": "Variantes", - "provider.custom.models.variants.add": "Adicionar variante", - "provider.custom.models.variants.remove": "Remover variante", - "provider.custom.models.variants.name.label": "Nome", - "provider.custom.models.variants.name.placeholder": "ex. thinking", - "provider.custom.models.variants.option.unset": "(não definido)", - "provider.custom.models.variants.enableThinking.label": "Ativar pensamento (ex. Alibaba)", - "provider.custom.models.variants.enableThinking.placeholder": "enable_thinking", - "provider.custom.models.variants.enableThinking.true": "true", - "provider.custom.models.variants.enableThinking.false": "false", - "provider.custom.models.variants.thinking.label": "Tipo de pensamento (ex. Z.ai)", - "provider.custom.models.variants.thinking.placeholder": "thinking", - "provider.custom.models.variants.thinking.enabled": "enabled", - "provider.custom.models.variants.thinking.disabled": "disabled", - "provider.custom.models.variants.thinking.adaptive": "adaptive", - "provider.custom.models.variants.splitReasoning.label": "Split reasoning (required for e.g. MiniMax)", - "provider.custom.models.variants.splitReasoning.placeholder": "reasoning_split", - "provider.custom.models.variants.splitReasoning.true": "true", - "provider.custom.models.variants.splitReasoning.false": "false", - "provider.custom.models.variants.chatTemplateArgs.label": - "Ativar pensamento via args do template de chat (ex. Hugging Face)", - "provider.custom.models.variants.chatTemplateArgs.placeholder": "chat_template_args", - "provider.custom.models.variants.chatTemplateArgs.true": "true", - "provider.custom.models.variants.chatTemplateArgs.false": "false", - "provider.custom.models.variants.reasoningEffort.label": "Esforço de raciocínio", - "provider.custom.models.variants.reasoningEffort.placeholder": "reasoningEffort", - "provider.custom.models.variants.reasoningEffort.none": "none", - "provider.custom.models.variants.reasoningEffort.minimal": "minimal", - "provider.custom.models.variants.reasoningEffort.low": "low", - "provider.custom.models.variants.reasoningEffort.medium": "medium", - "provider.custom.models.variants.reasoningEffort.high": "high", - "provider.custom.models.variants.reasoningEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.label": "Output effort (e.g. Anthropic)", - "provider.custom.models.variants.outputEffort.placeholder": "effort", - "provider.custom.models.variants.outputEffort.low": "low", - "provider.custom.models.variants.outputEffort.medium": "medium", - "provider.custom.models.variants.outputEffort.high": "high", - "provider.custom.models.variants.outputEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.max": "max", "provider.custom.models.remove": "Remover modelo", "provider.custom.models.add": "Adicionar modelo", "provider.custom.models.fetch.authError": "Falha na autenticação. Verifique a chave de API acima e tente novamente.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 509f0265201..e478af80f20 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -486,45 +486,6 @@ export const dict = { "provider.custom.models.name.placeholder": "Naziv za prikaz", "provider.custom.models.reasoning.label": "Zaključivanje", "provider.custom.models.modalities.image": "Slika", - "provider.custom.models.variants.label": "Varijante", - "provider.custom.models.variants.add": "Dodaj varijantu", - "provider.custom.models.variants.remove": "Ukloni varijantu", - "provider.custom.models.variants.name.label": "Ime", - "provider.custom.models.variants.name.placeholder": "npr. thinking", - "provider.custom.models.variants.option.unset": "(nije postavljeno)", - "provider.custom.models.variants.enableThinking.label": "Omogući razmišljanje (npr. Alibaba)", - "provider.custom.models.variants.enableThinking.placeholder": "enable_thinking", - "provider.custom.models.variants.enableThinking.true": "true", - "provider.custom.models.variants.enableThinking.false": "false", - "provider.custom.models.variants.thinking.label": "Vrsta razmišljanja (npr. Z.ai)", - "provider.custom.models.variants.thinking.placeholder": "thinking", - "provider.custom.models.variants.thinking.enabled": "enabled", - "provider.custom.models.variants.thinking.disabled": "disabled", - "provider.custom.models.variants.thinking.adaptive": "adaptive", - "provider.custom.models.variants.splitReasoning.label": "Split reasoning (required for e.g. MiniMax)", - "provider.custom.models.variants.splitReasoning.placeholder": "reasoning_split", - "provider.custom.models.variants.splitReasoning.true": "true", - "provider.custom.models.variants.splitReasoning.false": "false", - "provider.custom.models.variants.chatTemplateArgs.label": - "Omogući razmišljanje preko argumenata chat predloška (npr. Hugging Face)", - "provider.custom.models.variants.chatTemplateArgs.placeholder": "chat_template_args", - "provider.custom.models.variants.chatTemplateArgs.true": "true", - "provider.custom.models.variants.chatTemplateArgs.false": "false", - "provider.custom.models.variants.reasoningEffort.label": "Napor zaključivanja", - "provider.custom.models.variants.reasoningEffort.placeholder": "reasoningEffort", - "provider.custom.models.variants.reasoningEffort.none": "none", - "provider.custom.models.variants.reasoningEffort.minimal": "minimal", - "provider.custom.models.variants.reasoningEffort.low": "low", - "provider.custom.models.variants.reasoningEffort.medium": "medium", - "provider.custom.models.variants.reasoningEffort.high": "high", - "provider.custom.models.variants.reasoningEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.label": "Output effort (e.g. Anthropic)", - "provider.custom.models.variants.outputEffort.placeholder": "effort", - "provider.custom.models.variants.outputEffort.low": "low", - "provider.custom.models.variants.outputEffort.medium": "medium", - "provider.custom.models.variants.outputEffort.high": "high", - "provider.custom.models.variants.outputEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.max": "max", "provider.custom.models.remove": "Ukloni model", "provider.custom.models.add": "Dodaj model", "provider.custom.models.fetch.authError": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 4f1d6c9034c..7ef37266d21 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -484,45 +484,6 @@ export const dict = { "provider.custom.models.name.placeholder": "Visningsnavn", "provider.custom.models.reasoning.label": "Ræsonnement", "provider.custom.models.modalities.image": "Billede", - "provider.custom.models.variants.label": "Varianter", - "provider.custom.models.variants.add": "Tilføj variant", - "provider.custom.models.variants.remove": "Fjern variant", - "provider.custom.models.variants.name.label": "Navn", - "provider.custom.models.variants.name.placeholder": "f.eks. thinking", - "provider.custom.models.variants.option.unset": "(ikke angivet)", - "provider.custom.models.variants.enableThinking.label": "Aktivér tænkning (f.eks. Alibaba)", - "provider.custom.models.variants.enableThinking.placeholder": "enable_thinking", - "provider.custom.models.variants.enableThinking.true": "true", - "provider.custom.models.variants.enableThinking.false": "false", - "provider.custom.models.variants.thinking.label": "Tænkningstype (f.eks. Z.ai)", - "provider.custom.models.variants.thinking.placeholder": "thinking", - "provider.custom.models.variants.thinking.enabled": "enabled", - "provider.custom.models.variants.thinking.disabled": "disabled", - "provider.custom.models.variants.thinking.adaptive": "adaptive", - "provider.custom.models.variants.splitReasoning.label": "Split reasoning (required for e.g. MiniMax)", - "provider.custom.models.variants.splitReasoning.placeholder": "reasoning_split", - "provider.custom.models.variants.splitReasoning.true": "true", - "provider.custom.models.variants.splitReasoning.false": "false", - "provider.custom.models.variants.chatTemplateArgs.label": - "Aktivér tænkning via chat-skabelonargs (f.eks. Hugging Face)", - "provider.custom.models.variants.chatTemplateArgs.placeholder": "chat_template_args", - "provider.custom.models.variants.chatTemplateArgs.true": "true", - "provider.custom.models.variants.chatTemplateArgs.false": "false", - "provider.custom.models.variants.reasoningEffort.label": "Ræsonnementsindsats", - "provider.custom.models.variants.reasoningEffort.placeholder": "reasoningEffort", - "provider.custom.models.variants.reasoningEffort.none": "none", - "provider.custom.models.variants.reasoningEffort.minimal": "minimal", - "provider.custom.models.variants.reasoningEffort.low": "low", - "provider.custom.models.variants.reasoningEffort.medium": "medium", - "provider.custom.models.variants.reasoningEffort.high": "high", - "provider.custom.models.variants.reasoningEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.label": "Output effort (e.g. Anthropic)", - "provider.custom.models.variants.outputEffort.placeholder": "effort", - "provider.custom.models.variants.outputEffort.low": "low", - "provider.custom.models.variants.outputEffort.medium": "medium", - "provider.custom.models.variants.outputEffort.high": "high", - "provider.custom.models.variants.outputEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.max": "max", "provider.custom.models.remove": "Fjern model", "provider.custom.models.add": "Tilføj model", "provider.custom.models.fetch.authError": "Godkendelse mislykkedes. Kontrollér API-nøglen ovenfor, og prøv igen.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 7c1366c7261..8eaedf2be46 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -494,45 +494,6 @@ export const dict = { "provider.custom.models.name.placeholder": "Anzeigename", "provider.custom.models.reasoning.label": "Schlussfolgerung", "provider.custom.models.modalities.image": "Bild", - "provider.custom.models.variants.label": "Varianten", - "provider.custom.models.variants.add": "Variante hinzufügen", - "provider.custom.models.variants.remove": "Variante entfernen", - "provider.custom.models.variants.name.label": "Name", - "provider.custom.models.variants.name.placeholder": "z.B. thinking", - "provider.custom.models.variants.option.unset": "(nicht festgelegt)", - "provider.custom.models.variants.enableThinking.label": "Nachdenken aktivieren (z.B. Alibaba)", - "provider.custom.models.variants.enableThinking.placeholder": "enable_thinking", - "provider.custom.models.variants.enableThinking.true": "true", - "provider.custom.models.variants.enableThinking.false": "false", - "provider.custom.models.variants.thinking.label": "Art des Nachdenkens (z.B. Z.ai)", - "provider.custom.models.variants.thinking.placeholder": "thinking", - "provider.custom.models.variants.thinking.enabled": "enabled", - "provider.custom.models.variants.thinking.disabled": "disabled", - "provider.custom.models.variants.thinking.adaptive": "adaptive", - "provider.custom.models.variants.splitReasoning.label": "Split reasoning (required for e.g. MiniMax)", - "provider.custom.models.variants.splitReasoning.placeholder": "reasoning_split", - "provider.custom.models.variants.splitReasoning.true": "true", - "provider.custom.models.variants.splitReasoning.false": "false", - "provider.custom.models.variants.chatTemplateArgs.label": - "Nachdenken über Chat-Vorlagenargumente aktivieren (z.B. Hugging Face)", - "provider.custom.models.variants.chatTemplateArgs.placeholder": "chat_template_args", - "provider.custom.models.variants.chatTemplateArgs.true": "true", - "provider.custom.models.variants.chatTemplateArgs.false": "false", - "provider.custom.models.variants.reasoningEffort.label": "Reasoning-Aufwand", - "provider.custom.models.variants.reasoningEffort.placeholder": "reasoningEffort", - "provider.custom.models.variants.reasoningEffort.none": "none", - "provider.custom.models.variants.reasoningEffort.minimal": "minimal", - "provider.custom.models.variants.reasoningEffort.low": "low", - "provider.custom.models.variants.reasoningEffort.medium": "medium", - "provider.custom.models.variants.reasoningEffort.high": "high", - "provider.custom.models.variants.reasoningEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.label": "Output effort (e.g. Anthropic)", - "provider.custom.models.variants.outputEffort.placeholder": "effort", - "provider.custom.models.variants.outputEffort.low": "low", - "provider.custom.models.variants.outputEffort.medium": "medium", - "provider.custom.models.variants.outputEffort.high": "high", - "provider.custom.models.variants.outputEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.max": "max", "provider.custom.models.remove": "Modell entfernen", "provider.custom.models.add": "Modell hinzufügen", "provider.custom.models.fetch.authError": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 5752f6b2a8c..35ab3e7e9fb 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -406,45 +406,6 @@ export const dict = { "provider.custom.models.name.placeholder": "Display Name", "provider.custom.models.reasoning.label": "Reasoning", "provider.custom.models.modalities.image": "Image", - "provider.custom.models.variants.label": "Variants", - "provider.custom.models.variants.add": "Add variant", - "provider.custom.models.variants.remove": "Remove variant", - "provider.custom.models.variants.name.label": "Name", - "provider.custom.models.variants.name.placeholder": "e.g. thinking", - "provider.custom.models.variants.option.unset": "(not set)", - "provider.custom.models.variants.enableThinking.label": "Enable thinking (e.g. Alibaba)", - "provider.custom.models.variants.enableThinking.placeholder": "enable_thinking", - "provider.custom.models.variants.enableThinking.true": "true", - "provider.custom.models.variants.enableThinking.false": "false", - "provider.custom.models.variants.thinking.label": "Thinking type (e.g. Z.ai)", - "provider.custom.models.variants.thinking.placeholder": "thinking", - "provider.custom.models.variants.thinking.enabled": "enabled", - "provider.custom.models.variants.thinking.disabled": "disabled", - "provider.custom.models.variants.thinking.adaptive": "adaptive", - "provider.custom.models.variants.splitReasoning.label": "Split reasoning (required for e.g. MiniMax)", - "provider.custom.models.variants.splitReasoning.placeholder": "reasoning_split", - "provider.custom.models.variants.splitReasoning.true": "true", - "provider.custom.models.variants.splitReasoning.false": "false", - "provider.custom.models.variants.chatTemplateArgs.label": - "Enable thinking via chat template args (e.g. Hugging Face)", - "provider.custom.models.variants.chatTemplateArgs.placeholder": "chat_template_args", - "provider.custom.models.variants.chatTemplateArgs.true": "true", - "provider.custom.models.variants.chatTemplateArgs.false": "false", - "provider.custom.models.variants.reasoningEffort.label": "Reasoning effort", - "provider.custom.models.variants.reasoningEffort.placeholder": "reasoningEffort", - "provider.custom.models.variants.reasoningEffort.none": "none", - "provider.custom.models.variants.reasoningEffort.minimal": "minimal", - "provider.custom.models.variants.reasoningEffort.low": "low", - "provider.custom.models.variants.reasoningEffort.medium": "medium", - "provider.custom.models.variants.reasoningEffort.high": "high", - "provider.custom.models.variants.reasoningEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.label": "Output effort (e.g. Anthropic)", - "provider.custom.models.variants.outputEffort.placeholder": "effort", - "provider.custom.models.variants.outputEffort.low": "low", - "provider.custom.models.variants.outputEffort.medium": "medium", - "provider.custom.models.variants.outputEffort.high": "high", - "provider.custom.models.variants.outputEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.max": "max", "provider.custom.models.remove": "Remove model", "provider.custom.models.add": "Add model", "provider.custom.models.fetch.authError": "Authentication failed. Check the API key above and try again.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 2b7ed694b0b..db2015b6543 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -487,45 +487,6 @@ export const dict = { "provider.custom.models.name.placeholder": "Nombre para mostrar", "provider.custom.models.reasoning.label": "Razonamiento", "provider.custom.models.modalities.image": "Imagen", - "provider.custom.models.variants.label": "Variantes", - "provider.custom.models.variants.add": "Añadir variante", - "provider.custom.models.variants.remove": "Eliminar variante", - "provider.custom.models.variants.name.label": "Nombre", - "provider.custom.models.variants.name.placeholder": "p. ej. thinking", - "provider.custom.models.variants.option.unset": "(no establecido)", - "provider.custom.models.variants.enableThinking.label": "Habilitar pensamiento (p. ej. Alibaba)", - "provider.custom.models.variants.enableThinking.placeholder": "enable_thinking", - "provider.custom.models.variants.enableThinking.true": "true", - "provider.custom.models.variants.enableThinking.false": "false", - "provider.custom.models.variants.thinking.label": "Tipo de pensamiento (p. ej. Z.ai)", - "provider.custom.models.variants.thinking.placeholder": "thinking", - "provider.custom.models.variants.thinking.enabled": "enabled", - "provider.custom.models.variants.thinking.disabled": "disabled", - "provider.custom.models.variants.thinking.adaptive": "adaptive", - "provider.custom.models.variants.splitReasoning.label": "Split reasoning (required for e.g. MiniMax)", - "provider.custom.models.variants.splitReasoning.placeholder": "reasoning_split", - "provider.custom.models.variants.splitReasoning.true": "true", - "provider.custom.models.variants.splitReasoning.false": "false", - "provider.custom.models.variants.chatTemplateArgs.label": - "Activar pensamiento mediante args de plantilla de chat (ej. Hugging Face)", - "provider.custom.models.variants.chatTemplateArgs.placeholder": "chat_template_args", - "provider.custom.models.variants.chatTemplateArgs.true": "true", - "provider.custom.models.variants.chatTemplateArgs.false": "false", - "provider.custom.models.variants.reasoningEffort.label": "Esfuerzo de razonamiento", - "provider.custom.models.variants.reasoningEffort.placeholder": "reasoningEffort", - "provider.custom.models.variants.reasoningEffort.none": "none", - "provider.custom.models.variants.reasoningEffort.minimal": "minimal", - "provider.custom.models.variants.reasoningEffort.low": "low", - "provider.custom.models.variants.reasoningEffort.medium": "medium", - "provider.custom.models.variants.reasoningEffort.high": "high", - "provider.custom.models.variants.reasoningEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.label": "Output effort (e.g. Anthropic)", - "provider.custom.models.variants.outputEffort.placeholder": "effort", - "provider.custom.models.variants.outputEffort.low": "low", - "provider.custom.models.variants.outputEffort.medium": "medium", - "provider.custom.models.variants.outputEffort.high": "high", - "provider.custom.models.variants.outputEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.max": "max", "provider.custom.models.remove": "Eliminar modelo", "provider.custom.models.add": "Añadir modelo", "provider.custom.models.fetch.authError": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts index 71b97a2d3ff..85eb22ea3ba 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -407,45 +407,6 @@ export const dict = { "provider.custom.models.name.placeholder": "نام نمایشی", "provider.custom.models.reasoning.label": "استدلال", "provider.custom.models.modalities.image": "تصویر", - "provider.custom.models.variants.label": "نسخه‌های متغیر", - "provider.custom.models.variants.add": "افزودن نسخه متغیر", - "provider.custom.models.variants.remove": "حذف نسخه متغیر", - "provider.custom.models.variants.name.label": "نام", - "provider.custom.models.variants.name.placeholder": "مثلاً thinking", - "provider.custom.models.variants.option.unset": "(تنظیم نشده)", - "provider.custom.models.variants.enableThinking.label": "فعال‌سازی تفکر (مثلاً Alibaba)", - "provider.custom.models.variants.enableThinking.placeholder": "enable_thinking", - "provider.custom.models.variants.enableThinking.true": "true", - "provider.custom.models.variants.enableThinking.false": "false", - "provider.custom.models.variants.thinking.label": "نوع تفکر (مثلاً Z.ai)", - "provider.custom.models.variants.thinking.placeholder": "thinking", - "provider.custom.models.variants.thinking.enabled": "فعال", - "provider.custom.models.variants.thinking.disabled": "غیرفعال", - "provider.custom.models.variants.thinking.adaptive": "تطبیقی", - "provider.custom.models.variants.splitReasoning.label": "تقسیم استدلال (لازم برای مثلاً MiniMax)", - "provider.custom.models.variants.splitReasoning.placeholder": "reasoning_split", - "provider.custom.models.variants.splitReasoning.true": "true", - "provider.custom.models.variants.splitReasoning.false": "false", - "provider.custom.models.variants.chatTemplateArgs.label": - "فعال‌سازی تفکر از طریق آرگومان‌های قالب چت (مثلاً Hugging Face)", - "provider.custom.models.variants.chatTemplateArgs.placeholder": "chat_template_args", - "provider.custom.models.variants.chatTemplateArgs.true": "true", - "provider.custom.models.variants.chatTemplateArgs.false": "false", - "provider.custom.models.variants.reasoningEffort.label": "سطح استدلال", - "provider.custom.models.variants.reasoningEffort.placeholder": "reasoningEffort", - "provider.custom.models.variants.reasoningEffort.none": "هیچ", - "provider.custom.models.variants.reasoningEffort.minimal": "حداقل", - "provider.custom.models.variants.reasoningEffort.low": "کم", - "provider.custom.models.variants.reasoningEffort.medium": "متوسط", - "provider.custom.models.variants.reasoningEffort.high": "زیاد", - "provider.custom.models.variants.reasoningEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.label": "تلاش خروجی (مثلاً Anthropic)", - "provider.custom.models.variants.outputEffort.placeholder": "تلاش", - "provider.custom.models.variants.outputEffort.low": "کم", - "provider.custom.models.variants.outputEffort.medium": "متوسط", - "provider.custom.models.variants.outputEffort.high": "زیاد", - "provider.custom.models.variants.outputEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.max": "حداکثر", "provider.custom.models.remove": "حذف مدل", "provider.custom.models.add": "افزودن مدل", "provider.custom.models.fetch.authError": "احراز هویت ناموفق بود. کلید API بالا را بررسی کرده و دوباره امتحان کنید.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 3fd9956d9de..cec3e454773 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -488,45 +488,6 @@ export const dict = { "provider.custom.models.name.placeholder": "Nom d'affichage", "provider.custom.models.reasoning.label": "Raisonnement", "provider.custom.models.modalities.image": "Image", - "provider.custom.models.variants.label": "Variantes", - "provider.custom.models.variants.add": "Ajouter une variante", - "provider.custom.models.variants.remove": "Supprimer la variante", - "provider.custom.models.variants.name.label": "Nom", - "provider.custom.models.variants.name.placeholder": "ex. thinking", - "provider.custom.models.variants.option.unset": "(non défini)", - "provider.custom.models.variants.enableThinking.label": "Activer la réflexion (ex. Alibaba)", - "provider.custom.models.variants.enableThinking.placeholder": "enable_thinking", - "provider.custom.models.variants.enableThinking.true": "true", - "provider.custom.models.variants.enableThinking.false": "false", - "provider.custom.models.variants.thinking.label": "Type de réflexion (ex. Z.ai)", - "provider.custom.models.variants.thinking.placeholder": "thinking", - "provider.custom.models.variants.thinking.enabled": "enabled", - "provider.custom.models.variants.thinking.disabled": "disabled", - "provider.custom.models.variants.thinking.adaptive": "adaptive", - "provider.custom.models.variants.splitReasoning.label": "Split reasoning (required for e.g. MiniMax)", - "provider.custom.models.variants.splitReasoning.placeholder": "reasoning_split", - "provider.custom.models.variants.splitReasoning.true": "true", - "provider.custom.models.variants.splitReasoning.false": "false", - "provider.custom.models.variants.chatTemplateArgs.label": - "Activer la réflexion via les args du modèle de chat (ex. Hugging Face)", - "provider.custom.models.variants.chatTemplateArgs.placeholder": "chat_template_args", - "provider.custom.models.variants.chatTemplateArgs.true": "true", - "provider.custom.models.variants.chatTemplateArgs.false": "false", - "provider.custom.models.variants.reasoningEffort.label": "Effort de raisonnement", - "provider.custom.models.variants.reasoningEffort.placeholder": "reasoningEffort", - "provider.custom.models.variants.reasoningEffort.none": "none", - "provider.custom.models.variants.reasoningEffort.minimal": "minimal", - "provider.custom.models.variants.reasoningEffort.low": "low", - "provider.custom.models.variants.reasoningEffort.medium": "medium", - "provider.custom.models.variants.reasoningEffort.high": "high", - "provider.custom.models.variants.reasoningEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.label": "Output effort (e.g. Anthropic)", - "provider.custom.models.variants.outputEffort.placeholder": "effort", - "provider.custom.models.variants.outputEffort.low": "low", - "provider.custom.models.variants.outputEffort.medium": "medium", - "provider.custom.models.variants.outputEffort.high": "high", - "provider.custom.models.variants.outputEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.max": "max", "provider.custom.models.remove": "Supprimer le modèle", "provider.custom.models.add": "Ajouter un modèle", "provider.custom.models.fetch.authError": "Échec de l'authentification. Vérifiez la clé API ci-dessus et réessayez.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index b8ec52156f4..4270684cd22 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -316,45 +316,6 @@ export const dict = { "provider.custom.models.name.placeholder": "Nome visualizzato", "provider.custom.models.reasoning.label": "Reasoning", "provider.custom.models.modalities.image": "Immagine", - "provider.custom.models.variants.label": "Variants", - "provider.custom.models.variants.add": "Aggiungi variante", - "provider.custom.models.variants.remove": "Rimuovi variante", - "provider.custom.models.variants.name.label": "Nome", - "provider.custom.models.variants.name.placeholder": "es. thinking", - "provider.custom.models.variants.option.unset": "(non impostato)", - "provider.custom.models.variants.enableThinking.label": "Abilita thinking (es. Alibaba)", - "provider.custom.models.variants.enableThinking.placeholder": "enable_thinking", - "provider.custom.models.variants.enableThinking.true": "true", - "provider.custom.models.variants.enableThinking.false": "false", - "provider.custom.models.variants.thinking.label": "Tipo thinking (es. Z.ai)", - "provider.custom.models.variants.thinking.placeholder": "thinking", - "provider.custom.models.variants.thinking.enabled": "enabled", - "provider.custom.models.variants.thinking.disabled": "disabled", - "provider.custom.models.variants.thinking.adaptive": "adaptive", - "provider.custom.models.variants.splitReasoning.label": "Split reasoning (required for e.g. MiniMax)", - "provider.custom.models.variants.splitReasoning.placeholder": "reasoning_split", - "provider.custom.models.variants.splitReasoning.true": "true", - "provider.custom.models.variants.splitReasoning.false": "false", - "provider.custom.models.variants.chatTemplateArgs.label": - "Enable thinking via chat template args (e.g. Hugging Face)", - "provider.custom.models.variants.chatTemplateArgs.placeholder": "chat_template_args", - "provider.custom.models.variants.chatTemplateArgs.true": "true", - "provider.custom.models.variants.chatTemplateArgs.false": "false", - "provider.custom.models.variants.reasoningEffort.label": "Sforzo di ragionamento", - "provider.custom.models.variants.reasoningEffort.placeholder": "reasoningEffort", - "provider.custom.models.variants.reasoningEffort.none": "none", - "provider.custom.models.variants.reasoningEffort.minimal": "minimal", - "provider.custom.models.variants.reasoningEffort.low": "low", - "provider.custom.models.variants.reasoningEffort.medium": "medium", - "provider.custom.models.variants.reasoningEffort.high": "high", - "provider.custom.models.variants.reasoningEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.label": "Output effort (e.g. Anthropic)", - "provider.custom.models.variants.outputEffort.placeholder": "effort", - "provider.custom.models.variants.outputEffort.low": "low", - "provider.custom.models.variants.outputEffort.medium": "medium", - "provider.custom.models.variants.outputEffort.high": "high", - "provider.custom.models.variants.outputEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.max": "max", "provider.custom.models.remove": "Rimuovi modello", "provider.custom.models.add": "Aggiungi modello", "provider.custom.models.fetch.authError": "Autenticazione non riuscita. Controlla l'API key sopra e riprova.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index fa1eda6f6b0..fe0e5b26a80 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -480,44 +480,6 @@ export const dict = { "provider.custom.models.name.placeholder": "表示名", "provider.custom.models.reasoning.label": "推論", "provider.custom.models.modalities.image": "画像", - "provider.custom.models.variants.label": "バリアント", - "provider.custom.models.variants.add": "バリアントを追加", - "provider.custom.models.variants.remove": "バリアントを削除", - "provider.custom.models.variants.name.label": "名前", - "provider.custom.models.variants.name.placeholder": "例: thinking", - "provider.custom.models.variants.option.unset": "(未設定)", - "provider.custom.models.variants.enableThinking.label": "思考を有効にする (例: Alibaba)", - "provider.custom.models.variants.enableThinking.placeholder": "enable_thinking", - "provider.custom.models.variants.enableThinking.true": "true", - "provider.custom.models.variants.enableThinking.false": "false", - "provider.custom.models.variants.thinking.label": "思考タイプ (例: Z.ai)", - "provider.custom.models.variants.thinking.placeholder": "thinking", - "provider.custom.models.variants.thinking.enabled": "enabled", - "provider.custom.models.variants.thinking.disabled": "disabled", - "provider.custom.models.variants.thinking.adaptive": "adaptive", - "provider.custom.models.variants.splitReasoning.label": "Split reasoning (required for e.g. MiniMax)", - "provider.custom.models.variants.splitReasoning.placeholder": "reasoning_split", - "provider.custom.models.variants.splitReasoning.true": "true", - "provider.custom.models.variants.splitReasoning.false": "false", - "provider.custom.models.variants.chatTemplateArgs.label": "チャットテンプレート引数で思考を有効化 (例: Hugging Face)", - "provider.custom.models.variants.chatTemplateArgs.placeholder": "chat_template_args", - "provider.custom.models.variants.chatTemplateArgs.true": "true", - "provider.custom.models.variants.chatTemplateArgs.false": "false", - "provider.custom.models.variants.reasoningEffort.label": "推論エフォート", - "provider.custom.models.variants.reasoningEffort.placeholder": "reasoningEffort", - "provider.custom.models.variants.reasoningEffort.none": "none", - "provider.custom.models.variants.reasoningEffort.minimal": "minimal", - "provider.custom.models.variants.reasoningEffort.low": "low", - "provider.custom.models.variants.reasoningEffort.medium": "medium", - "provider.custom.models.variants.reasoningEffort.high": "high", - "provider.custom.models.variants.reasoningEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.label": "Output effort (e.g. Anthropic)", - "provider.custom.models.variants.outputEffort.placeholder": "effort", - "provider.custom.models.variants.outputEffort.low": "low", - "provider.custom.models.variants.outputEffort.medium": "medium", - "provider.custom.models.variants.outputEffort.high": "high", - "provider.custom.models.variants.outputEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.max": "max", "provider.custom.models.remove": "モデルを削除", "provider.custom.models.add": "モデルを追加", "provider.custom.models.fetch.authError": "認証に失敗しました。上記のAPIキーを確認して再試行してください。", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 360ed69cb00..5d4a35c614e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -441,44 +441,6 @@ export const dict = { "provider.custom.models.name.placeholder": "표시 이름", "provider.custom.models.reasoning.label": "추론", "provider.custom.models.modalities.image": "이미지", - "provider.custom.models.variants.label": "변형", - "provider.custom.models.variants.add": "변형 추가", - "provider.custom.models.variants.remove": "변형 제거", - "provider.custom.models.variants.name.label": "이름", - "provider.custom.models.variants.name.placeholder": "예: thinking", - "provider.custom.models.variants.option.unset": "(설정되지 않음)", - "provider.custom.models.variants.enableThinking.label": "사고 활성화 (예: Alibaba)", - "provider.custom.models.variants.enableThinking.placeholder": "enable_thinking", - "provider.custom.models.variants.enableThinking.true": "true", - "provider.custom.models.variants.enableThinking.false": "false", - "provider.custom.models.variants.thinking.label": "사고 유형 (예: Z.ai)", - "provider.custom.models.variants.thinking.placeholder": "thinking", - "provider.custom.models.variants.thinking.enabled": "enabled", - "provider.custom.models.variants.thinking.disabled": "disabled", - "provider.custom.models.variants.thinking.adaptive": "adaptive", - "provider.custom.models.variants.splitReasoning.label": "Split reasoning (required for e.g. MiniMax)", - "provider.custom.models.variants.splitReasoning.placeholder": "reasoning_split", - "provider.custom.models.variants.splitReasoning.true": "true", - "provider.custom.models.variants.splitReasoning.false": "false", - "provider.custom.models.variants.chatTemplateArgs.label": "채팅 템플릿 인수로 사고 활성화 (예: Hugging Face)", - "provider.custom.models.variants.chatTemplateArgs.placeholder": "chat_template_args", - "provider.custom.models.variants.chatTemplateArgs.true": "true", - "provider.custom.models.variants.chatTemplateArgs.false": "false", - "provider.custom.models.variants.reasoningEffort.label": "추론 노력", - "provider.custom.models.variants.reasoningEffort.placeholder": "reasoningEffort", - "provider.custom.models.variants.reasoningEffort.none": "none", - "provider.custom.models.variants.reasoningEffort.minimal": "minimal", - "provider.custom.models.variants.reasoningEffort.low": "low", - "provider.custom.models.variants.reasoningEffort.medium": "medium", - "provider.custom.models.variants.reasoningEffort.high": "high", - "provider.custom.models.variants.reasoningEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.label": "Output effort (e.g. Anthropic)", - "provider.custom.models.variants.outputEffort.placeholder": "effort", - "provider.custom.models.variants.outputEffort.low": "low", - "provider.custom.models.variants.outputEffort.medium": "medium", - "provider.custom.models.variants.outputEffort.high": "high", - "provider.custom.models.variants.outputEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.max": "max", "provider.custom.models.remove": "모델 제거", "provider.custom.models.add": "모델 추가", "provider.custom.models.fetch.authError": "인증에 실패했습니다. 위의 API 키를 확인하고 다시 시도하세요.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 64f70685b4c..c3d0b3d28e0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -437,45 +437,6 @@ export const dict = { "provider.custom.models.name.placeholder": "Weergavenaam", "provider.custom.models.reasoning.label": "Redeneren", "provider.custom.models.modalities.image": "Afbeelding", - "provider.custom.models.variants.label": "Varianten", - "provider.custom.models.variants.add": "Variant toevoegen", - "provider.custom.models.variants.remove": "Variant verwijderen", - "provider.custom.models.variants.name.label": "Naam", - "provider.custom.models.variants.name.placeholder": "bijv. thinking", - "provider.custom.models.variants.option.unset": "(niet ingesteld)", - "provider.custom.models.variants.enableThinking.label": "Denken inschakelen (bijv. Alibaba)", - "provider.custom.models.variants.enableThinking.placeholder": "enable_thinking", - "provider.custom.models.variants.enableThinking.true": "true", - "provider.custom.models.variants.enableThinking.false": "false", - "provider.custom.models.variants.thinking.label": "Denktype (bijv. Z.ai)", - "provider.custom.models.variants.thinking.placeholder": "thinking", - "provider.custom.models.variants.thinking.enabled": "enabled", - "provider.custom.models.variants.thinking.disabled": "disabled", - "provider.custom.models.variants.thinking.adaptive": "adaptive", - "provider.custom.models.variants.splitReasoning.label": "Split reasoning (required for e.g. MiniMax)", - "provider.custom.models.variants.splitReasoning.placeholder": "reasoning_split", - "provider.custom.models.variants.splitReasoning.true": "true", - "provider.custom.models.variants.splitReasoning.false": "false", - "provider.custom.models.variants.chatTemplateArgs.label": - "Nadenken inschakelen via chat template args (bijv. Hugging Face)", - "provider.custom.models.variants.chatTemplateArgs.placeholder": "chat_template_args", - "provider.custom.models.variants.chatTemplateArgs.true": "true", - "provider.custom.models.variants.chatTemplateArgs.false": "false", - "provider.custom.models.variants.reasoningEffort.label": "Redeneerinspanning", - "provider.custom.models.variants.reasoningEffort.placeholder": "reasoningEffort", - "provider.custom.models.variants.reasoningEffort.none": "none", - "provider.custom.models.variants.reasoningEffort.minimal": "minimal", - "provider.custom.models.variants.reasoningEffort.low": "low", - "provider.custom.models.variants.reasoningEffort.medium": "medium", - "provider.custom.models.variants.reasoningEffort.high": "high", - "provider.custom.models.variants.reasoningEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.label": "Output effort (e.g. Anthropic)", - "provider.custom.models.variants.outputEffort.placeholder": "effort", - "provider.custom.models.variants.outputEffort.low": "low", - "provider.custom.models.variants.outputEffort.medium": "medium", - "provider.custom.models.variants.outputEffort.high": "high", - "provider.custom.models.variants.outputEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.max": "max", "provider.custom.models.remove": "Model verwijderen", "provider.custom.models.add": "Model toevoegen", "provider.custom.models.fetch.authError": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 103de2edff6..27a1abb7719 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -447,45 +447,6 @@ export const dict = { "provider.custom.models.name.placeholder": "Visningsnavn", "provider.custom.models.reasoning.label": "Resonnering", "provider.custom.models.modalities.image": "Bilde", - "provider.custom.models.variants.label": "Varianter", - "provider.custom.models.variants.add": "Legg til variant", - "provider.custom.models.variants.remove": "Fjern variant", - "provider.custom.models.variants.name.label": "Navn", - "provider.custom.models.variants.name.placeholder": "f.eks. thinking", - "provider.custom.models.variants.option.unset": "(ikke angitt)", - "provider.custom.models.variants.enableThinking.label": "Aktiver tenkning (f.eks. Alibaba)", - "provider.custom.models.variants.enableThinking.placeholder": "enable_thinking", - "provider.custom.models.variants.enableThinking.true": "true", - "provider.custom.models.variants.enableThinking.false": "false", - "provider.custom.models.variants.thinking.label": "Tenkningstype (f.eks. Z.ai)", - "provider.custom.models.variants.thinking.placeholder": "thinking", - "provider.custom.models.variants.thinking.enabled": "enabled", - "provider.custom.models.variants.thinking.disabled": "disabled", - "provider.custom.models.variants.thinking.adaptive": "adaptive", - "provider.custom.models.variants.splitReasoning.label": "Split reasoning (required for e.g. MiniMax)", - "provider.custom.models.variants.splitReasoning.placeholder": "reasoning_split", - "provider.custom.models.variants.splitReasoning.true": "true", - "provider.custom.models.variants.splitReasoning.false": "false", - "provider.custom.models.variants.chatTemplateArgs.label": - "Aktiver tenkning via chat-malargumenter (f.eks. Hugging Face)", - "provider.custom.models.variants.chatTemplateArgs.placeholder": "chat_template_args", - "provider.custom.models.variants.chatTemplateArgs.true": "true", - "provider.custom.models.variants.chatTemplateArgs.false": "false", - "provider.custom.models.variants.reasoningEffort.label": "Resonneringsinnsats", - "provider.custom.models.variants.reasoningEffort.placeholder": "reasoningEffort", - "provider.custom.models.variants.reasoningEffort.none": "none", - "provider.custom.models.variants.reasoningEffort.minimal": "minimal", - "provider.custom.models.variants.reasoningEffort.low": "low", - "provider.custom.models.variants.reasoningEffort.medium": "medium", - "provider.custom.models.variants.reasoningEffort.high": "high", - "provider.custom.models.variants.reasoningEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.label": "Output effort (e.g. Anthropic)", - "provider.custom.models.variants.outputEffort.placeholder": "effort", - "provider.custom.models.variants.outputEffort.low": "low", - "provider.custom.models.variants.outputEffort.medium": "medium", - "provider.custom.models.variants.outputEffort.high": "high", - "provider.custom.models.variants.outputEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.max": "max", "provider.custom.models.remove": "Fjern modell", "provider.custom.models.add": "Legg til modell", "provider.custom.models.fetch.authError": "Autentisering mislyktes. Sjekk API-nøkkelen ovenfor og prøv igjen.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 18830cde9be..d869a869989 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -442,45 +442,6 @@ export const dict = { "provider.custom.models.name.placeholder": "Nazwa wyświetlana", "provider.custom.models.reasoning.label": "Rozumowanie", "provider.custom.models.modalities.image": "Obraz", - "provider.custom.models.variants.label": "Warianty", - "provider.custom.models.variants.add": "Dodaj wariant", - "provider.custom.models.variants.remove": "Usuń wariant", - "provider.custom.models.variants.name.label": "Nazwa", - "provider.custom.models.variants.name.placeholder": "np. thinking", - "provider.custom.models.variants.option.unset": "(nie ustawiono)", - "provider.custom.models.variants.enableThinking.label": "Włącz myślenie (np. Alibaba)", - "provider.custom.models.variants.enableThinking.placeholder": "enable_thinking", - "provider.custom.models.variants.enableThinking.true": "true", - "provider.custom.models.variants.enableThinking.false": "false", - "provider.custom.models.variants.thinking.label": "Typ myślenia (np. Z.ai)", - "provider.custom.models.variants.thinking.placeholder": "thinking", - "provider.custom.models.variants.thinking.enabled": "enabled", - "provider.custom.models.variants.thinking.disabled": "disabled", - "provider.custom.models.variants.thinking.adaptive": "adaptive", - "provider.custom.models.variants.splitReasoning.label": "Split reasoning (required for e.g. MiniMax)", - "provider.custom.models.variants.splitReasoning.placeholder": "reasoning_split", - "provider.custom.models.variants.splitReasoning.true": "true", - "provider.custom.models.variants.splitReasoning.false": "false", - "provider.custom.models.variants.chatTemplateArgs.label": - "Włącz myślenie przez argumenty szablonu czatu (np. Hugging Face)", - "provider.custom.models.variants.chatTemplateArgs.placeholder": "chat_template_args", - "provider.custom.models.variants.chatTemplateArgs.true": "true", - "provider.custom.models.variants.chatTemplateArgs.false": "false", - "provider.custom.models.variants.reasoningEffort.label": "Wysiłek rozumowania", - "provider.custom.models.variants.reasoningEffort.placeholder": "reasoningEffort", - "provider.custom.models.variants.reasoningEffort.none": "none", - "provider.custom.models.variants.reasoningEffort.minimal": "minimal", - "provider.custom.models.variants.reasoningEffort.low": "low", - "provider.custom.models.variants.reasoningEffort.medium": "medium", - "provider.custom.models.variants.reasoningEffort.high": "high", - "provider.custom.models.variants.reasoningEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.label": "Output effort (e.g. Anthropic)", - "provider.custom.models.variants.outputEffort.placeholder": "effort", - "provider.custom.models.variants.outputEffort.low": "low", - "provider.custom.models.variants.outputEffort.medium": "medium", - "provider.custom.models.variants.outputEffort.high": "high", - "provider.custom.models.variants.outputEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.max": "max", "provider.custom.models.remove": "Usuń model", "provider.custom.models.add": "Dodaj model", "provider.custom.models.fetch.authError": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 5eca69def5d..e0efa575acd 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -481,45 +481,6 @@ export const dict = { "provider.custom.models.name.placeholder": "Отображаемое имя", "provider.custom.models.reasoning.label": "Рассуждение", "provider.custom.models.modalities.image": "Изображение", - "provider.custom.models.variants.label": "Варианты", - "provider.custom.models.variants.add": "Добавить вариант", - "provider.custom.models.variants.remove": "Удалить вариант", - "provider.custom.models.variants.name.label": "Имя", - "provider.custom.models.variants.name.placeholder": "напр. thinking", - "provider.custom.models.variants.option.unset": "(не задано)", - "provider.custom.models.variants.enableThinking.label": "Включить мышление (напр. Alibaba)", - "provider.custom.models.variants.enableThinking.placeholder": "enable_thinking", - "provider.custom.models.variants.enableThinking.true": "true", - "provider.custom.models.variants.enableThinking.false": "false", - "provider.custom.models.variants.thinking.label": "Тип мышления (напр. Z.ai)", - "provider.custom.models.variants.thinking.placeholder": "thinking", - "provider.custom.models.variants.thinking.enabled": "enabled", - "provider.custom.models.variants.thinking.disabled": "disabled", - "provider.custom.models.variants.thinking.adaptive": "adaptive", - "provider.custom.models.variants.splitReasoning.label": "Split reasoning (required for e.g. MiniMax)", - "provider.custom.models.variants.splitReasoning.placeholder": "reasoning_split", - "provider.custom.models.variants.splitReasoning.true": "true", - "provider.custom.models.variants.splitReasoning.false": "false", - "provider.custom.models.variants.chatTemplateArgs.label": - "Включить размышление через аргументы шаблона чата (напр. Hugging Face)", - "provider.custom.models.variants.chatTemplateArgs.placeholder": "chat_template_args", - "provider.custom.models.variants.chatTemplateArgs.true": "true", - "provider.custom.models.variants.chatTemplateArgs.false": "false", - "provider.custom.models.variants.reasoningEffort.label": "Усилие рассуждения", - "provider.custom.models.variants.reasoningEffort.placeholder": "reasoningEffort", - "provider.custom.models.variants.reasoningEffort.none": "none", - "provider.custom.models.variants.reasoningEffort.minimal": "minimal", - "provider.custom.models.variants.reasoningEffort.low": "low", - "provider.custom.models.variants.reasoningEffort.medium": "medium", - "provider.custom.models.variants.reasoningEffort.high": "high", - "provider.custom.models.variants.reasoningEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.label": "Output effort (e.g. Anthropic)", - "provider.custom.models.variants.outputEffort.placeholder": "effort", - "provider.custom.models.variants.outputEffort.low": "low", - "provider.custom.models.variants.outputEffort.medium": "medium", - "provider.custom.models.variants.outputEffort.high": "high", - "provider.custom.models.variants.outputEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.max": "max", "provider.custom.models.remove": "Удалить модель", "provider.custom.models.add": "Добавить модель", "provider.custom.models.fetch.authError": "Ошибка аутентификации. Проверьте API-ключ выше и попробуйте снова.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index c4dbc68bcf5..2393f6c3eed 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -478,45 +478,6 @@ export const dict = { "provider.custom.models.name.placeholder": "ชื่อที่แสดง", "provider.custom.models.reasoning.label": "การใช้เหตุผล", "provider.custom.models.modalities.image": "รูปภาพ", - "provider.custom.models.variants.label": "รูปแบบ", - "provider.custom.models.variants.add": "เพิ่มรูปแบบ", - "provider.custom.models.variants.remove": "ลบรูปแบบ", - "provider.custom.models.variants.name.label": "ชื่อ", - "provider.custom.models.variants.name.placeholder": "เช่น thinking", - "provider.custom.models.variants.option.unset": "(ไม่ได้ตั้งค่า)", - "provider.custom.models.variants.enableThinking.label": "เปิดใช้งานการคิด (เช่น Alibaba)", - "provider.custom.models.variants.enableThinking.placeholder": "enable_thinking", - "provider.custom.models.variants.enableThinking.true": "true", - "provider.custom.models.variants.enableThinking.false": "false", - "provider.custom.models.variants.thinking.label": "ประเภทการคิด (เช่น Z.ai)", - "provider.custom.models.variants.thinking.placeholder": "thinking", - "provider.custom.models.variants.thinking.enabled": "enabled", - "provider.custom.models.variants.thinking.disabled": "disabled", - "provider.custom.models.variants.thinking.adaptive": "adaptive", - "provider.custom.models.variants.splitReasoning.label": "Split reasoning (required for e.g. MiniMax)", - "provider.custom.models.variants.splitReasoning.placeholder": "reasoning_split", - "provider.custom.models.variants.splitReasoning.true": "true", - "provider.custom.models.variants.splitReasoning.false": "false", - "provider.custom.models.variants.chatTemplateArgs.label": - "เปิดใช้งานการคิดผ่านอาร์กิวเมนต์เทมเพลตแชท (เช่น Hugging Face)", - "provider.custom.models.variants.chatTemplateArgs.placeholder": "chat_template_args", - "provider.custom.models.variants.chatTemplateArgs.true": "true", - "provider.custom.models.variants.chatTemplateArgs.false": "false", - "provider.custom.models.variants.reasoningEffort.label": "ระดับการใช้เหตุผล", - "provider.custom.models.variants.reasoningEffort.placeholder": "reasoningEffort", - "provider.custom.models.variants.reasoningEffort.none": "none", - "provider.custom.models.variants.reasoningEffort.minimal": "minimal", - "provider.custom.models.variants.reasoningEffort.low": "low", - "provider.custom.models.variants.reasoningEffort.medium": "medium", - "provider.custom.models.variants.reasoningEffort.high": "high", - "provider.custom.models.variants.reasoningEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.label": "Output effort (e.g. Anthropic)", - "provider.custom.models.variants.outputEffort.placeholder": "effort", - "provider.custom.models.variants.outputEffort.low": "low", - "provider.custom.models.variants.outputEffort.medium": "medium", - "provider.custom.models.variants.outputEffort.high": "high", - "provider.custom.models.variants.outputEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.max": "max", "provider.custom.models.remove": "ลบโมเดล", "provider.custom.models.add": "เพิ่มโมเดล", "provider.custom.models.fetch.authError": "การยืนยันตัวตนล้มเหลว ตรวจสอบคีย์ API ด้านบนแล้วลองอีกครั้ง", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 0761b27b94a..4f76d9c8778 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -432,45 +432,6 @@ export const dict = { "provider.custom.models.name.placeholder": "Görünen Ad", "provider.custom.models.reasoning.label": "Akıl Yürütme", "provider.custom.models.modalities.image": "Görüntü", - "provider.custom.models.variants.label": "Varyantlar", - "provider.custom.models.variants.add": "Varyant ekle", - "provider.custom.models.variants.remove": "Varyantı kaldır", - "provider.custom.models.variants.name.label": "Ad", - "provider.custom.models.variants.name.placeholder": "örn. thinking", - "provider.custom.models.variants.option.unset": "(ayarlanmadı)", - "provider.custom.models.variants.enableThinking.label": "Düşünmeyi etkinleştir (örn. Alibaba)", - "provider.custom.models.variants.enableThinking.placeholder": "enable_thinking", - "provider.custom.models.variants.enableThinking.true": "true", - "provider.custom.models.variants.enableThinking.false": "false", - "provider.custom.models.variants.thinking.label": "Düşünme türü (örn. Z.ai)", - "provider.custom.models.variants.thinking.placeholder": "thinking", - "provider.custom.models.variants.thinking.enabled": "enabled", - "provider.custom.models.variants.thinking.disabled": "disabled", - "provider.custom.models.variants.thinking.adaptive": "adaptive", - "provider.custom.models.variants.splitReasoning.label": "Split reasoning (required for e.g. MiniMax)", - "provider.custom.models.variants.splitReasoning.placeholder": "reasoning_split", - "provider.custom.models.variants.splitReasoning.true": "true", - "provider.custom.models.variants.splitReasoning.false": "false", - "provider.custom.models.variants.chatTemplateArgs.label": - "Sohbet şablonu argümanları ile düşünmeyi etkinleştir (ör. Hugging Face)", - "provider.custom.models.variants.chatTemplateArgs.placeholder": "chat_template_args", - "provider.custom.models.variants.chatTemplateArgs.true": "true", - "provider.custom.models.variants.chatTemplateArgs.false": "false", - "provider.custom.models.variants.reasoningEffort.label": "Akıl yürütme çabası", - "provider.custom.models.variants.reasoningEffort.placeholder": "reasoningEffort", - "provider.custom.models.variants.reasoningEffort.none": "none", - "provider.custom.models.variants.reasoningEffort.minimal": "minimal", - "provider.custom.models.variants.reasoningEffort.low": "low", - "provider.custom.models.variants.reasoningEffort.medium": "medium", - "provider.custom.models.variants.reasoningEffort.high": "high", - "provider.custom.models.variants.reasoningEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.label": "Output effort (e.g. Anthropic)", - "provider.custom.models.variants.outputEffort.placeholder": "effort", - "provider.custom.models.variants.outputEffort.low": "low", - "provider.custom.models.variants.outputEffort.medium": "medium", - "provider.custom.models.variants.outputEffort.high": "high", - "provider.custom.models.variants.outputEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.max": "max", "provider.custom.models.remove": "Modeli kaldır", "provider.custom.models.add": "Model ekle", "provider.custom.models.fetch.authError": diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index b4b8c529d24..9b47e1a3a24 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -436,45 +436,6 @@ export const dict = { "provider.custom.models.name.placeholder": "Відображувана назва", "provider.custom.models.reasoning.label": "Міркування", "provider.custom.models.modalities.image": "Зображення", - "provider.custom.models.variants.label": "Варіанти", - "provider.custom.models.variants.add": "Додати варіант", - "provider.custom.models.variants.remove": "Видалити варіант", - "provider.custom.models.variants.name.label": "Ім'я", - "provider.custom.models.variants.name.placeholder": "напр. thinking", - "provider.custom.models.variants.option.unset": "(не встановлено)", - "provider.custom.models.variants.enableThinking.label": "Увімкнути мислення (напр. Alibaba)", - "provider.custom.models.variants.enableThinking.placeholder": "enable_thinking", - "provider.custom.models.variants.enableThinking.true": "true", - "provider.custom.models.variants.enableThinking.false": "false", - "provider.custom.models.variants.thinking.label": "Тип мислення (напр. Z.ai)", - "provider.custom.models.variants.thinking.placeholder": "thinking", - "provider.custom.models.variants.thinking.enabled": "enabled", - "provider.custom.models.variants.thinking.disabled": "disabled", - "provider.custom.models.variants.thinking.adaptive": "adaptive", - "provider.custom.models.variants.splitReasoning.label": "Split reasoning (required for e.g. MiniMax)", - "provider.custom.models.variants.splitReasoning.placeholder": "reasoning_split", - "provider.custom.models.variants.splitReasoning.true": "true", - "provider.custom.models.variants.splitReasoning.false": "false", - "provider.custom.models.variants.chatTemplateArgs.label": - "Увімкнути мислення через аргументи шаблону чату (напр. Hugging Face)", - "provider.custom.models.variants.chatTemplateArgs.placeholder": "chat_template_args", - "provider.custom.models.variants.chatTemplateArgs.true": "true", - "provider.custom.models.variants.chatTemplateArgs.false": "false", - "provider.custom.models.variants.reasoningEffort.label": "Зусилля міркування", - "provider.custom.models.variants.reasoningEffort.placeholder": "reasoningEffort", - "provider.custom.models.variants.reasoningEffort.none": "none", - "provider.custom.models.variants.reasoningEffort.minimal": "minimal", - "provider.custom.models.variants.reasoningEffort.low": "low", - "provider.custom.models.variants.reasoningEffort.medium": "medium", - "provider.custom.models.variants.reasoningEffort.high": "high", - "provider.custom.models.variants.reasoningEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.label": "Output effort (e.g. Anthropic)", - "provider.custom.models.variants.outputEffort.placeholder": "effort", - "provider.custom.models.variants.outputEffort.low": "low", - "provider.custom.models.variants.outputEffort.medium": "medium", - "provider.custom.models.variants.outputEffort.high": "high", - "provider.custom.models.variants.outputEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.max": "max", "provider.custom.models.remove": "Видалити модель", "provider.custom.models.add": "Додати модель", "provider.custom.models.fetch.authError": "Автентифікація не вдалася. Перевірте API-ключ вище і спробуйте ще раз.", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 041c72d116b..eadac1499ae 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -463,44 +463,6 @@ export const dict = { "provider.custom.models.name.placeholder": "显示名称", "provider.custom.models.reasoning.label": "推理", "provider.custom.models.modalities.image": "图片", - "provider.custom.models.variants.label": "变体", - "provider.custom.models.variants.add": "添加变体", - "provider.custom.models.variants.remove": "移除变体", - "provider.custom.models.variants.name.label": "名称", - "provider.custom.models.variants.name.placeholder": "例如 thinking", - "provider.custom.models.variants.option.unset": "(未设置)", - "provider.custom.models.variants.enableThinking.label": "启用思考 (例如 Alibaba)", - "provider.custom.models.variants.enableThinking.placeholder": "enable_thinking", - "provider.custom.models.variants.enableThinking.true": "true", - "provider.custom.models.variants.enableThinking.false": "false", - "provider.custom.models.variants.thinking.label": "思考类型 (例如 Z.ai)", - "provider.custom.models.variants.thinking.placeholder": "thinking", - "provider.custom.models.variants.thinking.enabled": "enabled", - "provider.custom.models.variants.thinking.disabled": "disabled", - "provider.custom.models.variants.thinking.adaptive": "adaptive", - "provider.custom.models.variants.splitReasoning.label": "Split reasoning (required for e.g. MiniMax)", - "provider.custom.models.variants.splitReasoning.placeholder": "reasoning_split", - "provider.custom.models.variants.splitReasoning.true": "true", - "provider.custom.models.variants.splitReasoning.false": "false", - "provider.custom.models.variants.chatTemplateArgs.label": "通过聊天模板参数启用思考(如 Hugging Face)", - "provider.custom.models.variants.chatTemplateArgs.placeholder": "chat_template_args", - "provider.custom.models.variants.chatTemplateArgs.true": "true", - "provider.custom.models.variants.chatTemplateArgs.false": "false", - "provider.custom.models.variants.reasoningEffort.label": "推理强度", - "provider.custom.models.variants.reasoningEffort.placeholder": "reasoningEffort", - "provider.custom.models.variants.reasoningEffort.none": "none", - "provider.custom.models.variants.reasoningEffort.minimal": "minimal", - "provider.custom.models.variants.reasoningEffort.low": "low", - "provider.custom.models.variants.reasoningEffort.medium": "medium", - "provider.custom.models.variants.reasoningEffort.high": "high", - "provider.custom.models.variants.reasoningEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.label": "Output effort (e.g. Anthropic)", - "provider.custom.models.variants.outputEffort.placeholder": "effort", - "provider.custom.models.variants.outputEffort.low": "low", - "provider.custom.models.variants.outputEffort.medium": "medium", - "provider.custom.models.variants.outputEffort.high": "high", - "provider.custom.models.variants.outputEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.max": "max", "provider.custom.models.remove": "移除模型", "provider.custom.models.add": "添加模型", "provider.custom.models.fetch.authError": "认证失败。请检查上方的 API 密钥后重试。", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 52ecca5fa9e..8cec3278a7a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -423,44 +423,6 @@ export const dict = { "provider.custom.models.name.placeholder": "顯示名稱", "provider.custom.models.reasoning.label": "推理", "provider.custom.models.modalities.image": "圖片", - "provider.custom.models.variants.label": "變體", - "provider.custom.models.variants.add": "新增變體", - "provider.custom.models.variants.remove": "移除變體", - "provider.custom.models.variants.name.label": "名稱", - "provider.custom.models.variants.name.placeholder": "例如 thinking", - "provider.custom.models.variants.option.unset": "(未設定)", - "provider.custom.models.variants.enableThinking.label": "啟用思考 (例如 Alibaba)", - "provider.custom.models.variants.enableThinking.placeholder": "enable_thinking", - "provider.custom.models.variants.enableThinking.true": "true", - "provider.custom.models.variants.enableThinking.false": "false", - "provider.custom.models.variants.thinking.label": "思考類型 (例如 Z.ai)", - "provider.custom.models.variants.thinking.placeholder": "thinking", - "provider.custom.models.variants.thinking.enabled": "enabled", - "provider.custom.models.variants.thinking.disabled": "disabled", - "provider.custom.models.variants.thinking.adaptive": "adaptive", - "provider.custom.models.variants.splitReasoning.label": "Split reasoning (required for e.g. MiniMax)", - "provider.custom.models.variants.splitReasoning.placeholder": "reasoning_split", - "provider.custom.models.variants.splitReasoning.true": "true", - "provider.custom.models.variants.splitReasoning.false": "false", - "provider.custom.models.variants.chatTemplateArgs.label": "透過聊天範本參數啟用思考(如 Hugging Face)", - "provider.custom.models.variants.chatTemplateArgs.placeholder": "chat_template_args", - "provider.custom.models.variants.chatTemplateArgs.true": "true", - "provider.custom.models.variants.chatTemplateArgs.false": "false", - "provider.custom.models.variants.reasoningEffort.label": "推理強度", - "provider.custom.models.variants.reasoningEffort.placeholder": "reasoningEffort", - "provider.custom.models.variants.reasoningEffort.none": "none", - "provider.custom.models.variants.reasoningEffort.minimal": "minimal", - "provider.custom.models.variants.reasoningEffort.low": "low", - "provider.custom.models.variants.reasoningEffort.medium": "medium", - "provider.custom.models.variants.reasoningEffort.high": "high", - "provider.custom.models.variants.reasoningEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.label": "Output effort (e.g. Anthropic)", - "provider.custom.models.variants.outputEffort.placeholder": "effort", - "provider.custom.models.variants.outputEffort.low": "low", - "provider.custom.models.variants.outputEffort.medium": "medium", - "provider.custom.models.variants.outputEffort.high": "high", - "provider.custom.models.variants.outputEffort.xhigh": "xhigh", - "provider.custom.models.variants.outputEffort.max": "max", "provider.custom.models.remove": "移除模型", "provider.custom.models.add": "新增模型", "provider.custom.models.fetch.authError": "驗證失敗。請檢查上方的 API 金鑰後重試。", diff --git a/packages/opencode/src/kilocode/provider/provider.ts b/packages/opencode/src/kilocode/provider/provider.ts index cc8a452fc96..13ec8bdddd2 100644 --- a/packages/opencode/src/kilocode/provider/provider.ts +++ b/packages/opencode/src/kilocode/provider/provider.ts @@ -15,6 +15,8 @@ import { ProviderError } from "@/provider/error" import { Effect, Schema } from "effect" import type { LanguageModelV3 } from "@ai-sdk/provider" import { mapValues, omit, pickBy } from "remeda" +import { reasoningSummary } from "./reasoning-summary" +import type { Provider } from "@/provider/provider" /** Default timeout (ms) for provider HTTP requests (connection phase). */ export const REQUEST_TIMEOUT_MS = 300_000 // 5 minutes @@ -95,6 +97,37 @@ export function patchConfigModel(cfg: any, existing: any) { } } +const CUSTOM_PROVIDER_PACKAGES = new Set(["@ai-sdk/openai-compatible", "@ai-sdk/openai", "@ai-sdk/anthropic"]) +const FALLBACK_EFFORTS = ["none", "low", "medium", "high", "xhigh", "max"] +type Variants = NonNullable +type Generate = (model: Provider.Model) => Variants + +export function customProviderVariants(model: Provider.Model, npm: unknown, generate: Generate): Variants { + const supported = typeof npm === "string" && CUSTOM_PROVIDER_PACKAGES.has(npm) && model.api.npm === npm + const variants = generate(supported ? { ...model, variants: {} } : model) + if (Object.keys(variants).length > 0) return variants + if (!model.capabilities.reasoning || !supported) return variants + + return Object.fromEntries( + FALLBACK_EFFORTS.map((effort) => { + if (npm === "@ai-sdk/anthropic") { + return [effort, effort === "none" ? { thinking: { type: "disabled" } } : { effort }] + } + if (npm === "@ai-sdk/openai") { + return [ + effort, + { + reasoningEffort: effort, + reasoningSummary: reasoningSummary(model), + include: ["reasoning.encrypted_content"], + }, + ] + } + return [effort, { reasoningEffort: effort }] + }), + ) +} + // --------------------------------------------------------------------------- // Custom loaders (new or fully-replaced loaders) // --------------------------------------------------------------------------- diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 897e8a6880f..e8f134f2871 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -37,6 +37,7 @@ import { KILO_MODEL_SCHEMA_EXTENSIONS, patchModelsDevModel as patchKiloModel, patchConfigModel as patchKiloConfigModel, + customProviderVariants, patchCustomLoaderResult, patchKiloProviderPrivacy, kiloSmallModelPriority, @@ -1526,7 +1527,14 @@ const layer = Layer.effect( // variants: {}, // kilocode_change, moved into patchKiloConfigModel ...patchKiloConfigModel(model, existingModel), // kilocode_change } - const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {}) + // kilocode_change start + const generated = customProviderVariants( + parsedModel, + model.provider?.npm ?? provider.npm, + ProviderTransform.variants, + ) + const merged = mergeDeep(generated, model.variants ?? {}) + // kilocode_change end parsedModel.variants = mapValues( pickBy(merged, (v): v is NonNullable => !!v && !v.disabled), // kilocode_change - drop null delete sentinels (v) => omit(v, ["disabled"]), diff --git a/packages/opencode/test/kilocode/custom-provider-variants.test.ts b/packages/opencode/test/kilocode/custom-provider-variants.test.ts new file mode 100644 index 00000000000..e3a6e6be179 --- /dev/null +++ b/packages/opencode/test/kilocode/custom-provider-variants.test.ts @@ -0,0 +1,46 @@ +import { expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { Effect } from "effect" +import { Env } from "@/env" +import { Plugin } from "@/plugin" +import { Provider } from "@/provider/provider" +import { testEffect } from "../lib/effect" + +const it = testEffect(LayerNode.compile(LayerNode.group([Provider.node, Env.node, Plugin.node]))) + +it.instance( + "adds fallback efforts to configured reasoning models and merges explicit variants", + () => + Effect.gen(function* () { + const providers = yield* Provider.use.list() + const model = providers[ProviderV2.ID.make("custom")]?.models["qwen-custom"] + + expect(Object.keys(model?.variants ?? {})).toEqual(["none", "low", "medium", "xhigh", "max", "custom"]) + expect(model?.variants?.none).toEqual({ reasoningEffort: "none" }) + expect(model?.variants?.high).toBeUndefined() + expect(model?.variants?.custom).toEqual({ reasoningEffort: "custom" }) + }), + { + config: { + provider: { + custom: { + name: "Custom", + npm: "@ai-sdk/openai-compatible", + options: { apiKey: "test" }, + models: { + "qwen-custom": { + name: "Qwen Custom", + reasoning: true, + limit: { context: 128_000, output: 16_000 }, + variants: { + high: { disabled: true }, + custom: { reasoningEffort: "custom" }, + }, + }, + }, + }, + }, + }, + }, +) diff --git a/packages/opencode/test/kilocode/provider-reasoning-options.test.ts b/packages/opencode/test/kilocode/provider-reasoning-options.test.ts index 711820ede3b..268d27f4399 100644 --- a/packages/opencode/test/kilocode/provider-reasoning-options.test.ts +++ b/packages/opencode/test/kilocode/provider-reasoning-options.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { ProviderTransform } from "../../src/provider/transform" import { Provider } from "../../src/provider/provider" +import { customProviderVariants } from "../../src/kilocode/provider/provider" import type * as ModelsDev from "@opencode-ai/core/models-dev" function mockModel(overrides: Partial = {}): any { @@ -138,3 +139,55 @@ describe("ProviderTransform.reasoningVariants - models.dev reasoning_options", ( expect(Object.keys(gpt5.variants ?? {})).toEqual(["minimal", "low", "medium", "high"]) }) }) + +describe("custom provider fallback reasoning efforts", () => { + const efforts = ["none", "low", "medium", "high", "xhigh", "max"] + + for (const npm of ["@ai-sdk/openai-compatible", "@ai-sdk/openai", "@ai-sdk/anthropic"]) { + test(`${npm} exposes broad efforts after heuristics fail`, () => { + const model = mockModel({ id: "qwen-custom", api: { id: "qwen-custom", url: "https://api.test.com", npm } }) + const generated = ProviderTransform.variants({ ...model, variants: {} }) + expect(generated).toEqual({}) + + const result = customProviderVariants(model, npm, ProviderTransform.variants) + + expect(Object.keys(result)).toEqual(efforts) + if (npm === "@ai-sdk/anthropic") { + expect(result.none).toEqual({ thinking: { type: "disabled" } }) + expect(result.max).toEqual({ effort: "max" }) + return + } + expect(result.none?.reasoningEffort).toBe("none") + expect(result.max?.reasoningEffort).toBe("max") + }) + } + + test("preserves successful heuristics", () => { + const model = mockModel({ api: { id: "custom", url: "https://api.test.com", npm: "@ai-sdk/openai-compatible" } }) + const generated = { low: { reasoningEffort: "low" }, high: { reasoningEffort: "high" } } + expect(customProviderVariants(model, model.api.npm, () => generated)).toBe(generated) + }) + + test("requires a reasoning model with an explicitly configured supported package", () => { + const npm = "@ai-sdk/openai-compatible" + const plain = mockModel({ + api: { id: "custom", url: "https://api.test.com", npm }, + capabilities: { ...mockModel().capabilities, reasoning: false }, + }) + expect(customProviderVariants(plain, npm, () => ({}))).toEqual({}) + expect( + customProviderVariants( + mockModel({ api: { id: "custom", url: "https://api.test.com", npm } }), + undefined, + () => ({}), + ), + ).toEqual({}) + expect( + customProviderVariants( + mockModel({ api: { id: "custom", url: "https://api.test.com", npm: "unrelated-provider" } }), + "unrelated-provider", + () => ({}), + ), + ).toEqual({}) + }) +}) From 71a1c6bc3d571aa464376117080680dcaf5588f2 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Thu, 6 Aug 2026 13:37:59 +0200 Subject: [PATCH 02/34] fix(vscode): surface custom variant validation --- .../settings/CustomProviderModelCard.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderModelCard.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderModelCard.tsx index fa440816f31..a33984738c3 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderModelCard.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderModelCard.tsx @@ -1,5 +1,6 @@ import { IconButton } from "@kilocode/kilo-ui/icon-button" import { TextField } from "@kilocode/kilo-ui/text-field" +import { Show } from "solid-js" import { useLanguage } from "../../context/language" export type Translator = ReturnType["t"] @@ -40,7 +41,7 @@ export type ModelEntry = { type ModelCardProps = { m: ModelEntry - errors: { id?: string; name?: string } + errors: { id?: string; name?: string; variants?: Array<{ name?: string }> } t: Translator canRemove: boolean onChangeId: (val: string) => void @@ -51,6 +52,8 @@ type ModelCardProps = { } export function ModelCard(props: ModelCardProps) { + const issue = () => props.errors.variants?.find((error) => error.name)?.name + return (
{props.t("provider.custom.models.modalities.image")} + + + {(error) => ( + + {error()} + + )} +
) } From 2dc24c1cfdd6271e253e1a7623639b043f33fd6f Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Thu, 6 Aug 2026 14:03:12 +0200 Subject: [PATCH 03/34] fix(vscode): identify invalid custom variants --- .../tests/unit/custom-provider-dialog-validate.test.ts | 6 +++--- .../src/components/settings/CustomProviderValidation.ts | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts b/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts index ff405481c06..814b2147e0b 100644 --- a/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts +++ b/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts @@ -93,7 +93,7 @@ describe("validateCustomProvider – variant name validation", () => { ] const out = validateCustomProvider(args(form)) expect(out.result).toBeUndefined() - expect(out.errors.models[0].variants?.[0]?.name).toBe("provider.custom.error.required") + expect(out.errors.models[0].variants?.[0]?.name).toBe('variants[""]: provider.custom.error.required') }) it("blocks submit and reports error when reasoning is enabled with a whitespace-only variant name", () => { @@ -112,7 +112,7 @@ describe("validateCustomProvider – variant name validation", () => { ] const out = validateCustomProvider(args(form)) expect(out.result).toBeUndefined() - expect(out.errors.models[0].variants?.[0]?.name).toBe("provider.custom.error.required") + expect(out.errors.models[0].variants?.[0]?.name).toBe('variants[" "]: provider.custom.error.required') }) it("blocks submit and reports duplicate error for two variants with the same name", () => { @@ -140,7 +140,7 @@ describe("validateCustomProvider – variant name validation", () => { ] const out = validateCustomProvider(args(form)) expect(out.result).toBeUndefined() - expect(out.errors.models[0].variants?.[1]?.name).toBe("provider.custom.error.duplicate") + expect(out.errors.models[0].variants?.[1]?.name).toBe('variants["fast"]: provider.custom.error.duplicate') }) it("ignores variants entirely when reasoning is disabled, even if they have empty names", () => { diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderValidation.ts b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderValidation.ts index 1032e6cf91d..2300e9fe910 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderValidation.ts +++ b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderValidation.ts @@ -57,8 +57,9 @@ const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/ function checkVariant(v: VariantEntry, seen: Set, t: Translator) { const n = v.name.trim() - if (!n) return { name: t("provider.custom.error.required") } - if (seen.has(n)) return { name: t("provider.custom.error.duplicate") } + const path = `variants[${JSON.stringify(v.name)}]` + if (!n) return { name: `${path}: ${t("provider.custom.error.required")}` } + if (seen.has(n)) return { name: `${path}: ${t("provider.custom.error.duplicate")}` } seen.add(n) return { name: undefined } } From d70ebd319174b795412d1afcec97b32113801fc0 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Fri, 7 Aug 2026 09:47:38 +0200 Subject: [PATCH 04/34] fix(cli): prefer configured custom variants --- packages/opencode/src/kilocode/provider/provider.ts | 4 +++- packages/opencode/src/provider/provider.ts | 8 +++----- .../test/kilocode/custom-provider-variants.test.ts | 5 ++--- .../test/kilocode/provider-reasoning-options.test.ts | 12 ++++++++++++ 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/kilocode/provider/provider.ts b/packages/opencode/src/kilocode/provider/provider.ts index 13ec8bdddd2..21345881ee9 100644 --- a/packages/opencode/src/kilocode/provider/provider.ts +++ b/packages/opencode/src/kilocode/provider/provider.ts @@ -103,8 +103,10 @@ type Variants = NonNullable type Generate = (model: Provider.Model) => Variants export function customProviderVariants(model: Provider.Model, npm: unknown, generate: Generate): Variants { + if (model.variants && Object.keys(model.variants).length > 0) return model.variants + const supported = typeof npm === "string" && CUSTOM_PROVIDER_PACKAGES.has(npm) && model.api.npm === npm - const variants = generate(supported ? { ...model, variants: {} } : model) + const variants = generate(model) if (Object.keys(variants).length > 0) return variants if (!model.capabilities.reasoning || !supported) return variants diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index e8f134f2871..c830ad8bb78 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1528,11 +1528,9 @@ const layer = Layer.effect( ...patchKiloConfigModel(model, existingModel), // kilocode_change } // kilocode_change start - const generated = customProviderVariants( - parsedModel, - model.provider?.npm ?? provider.npm, - ProviderTransform.variants, - ) + const generated = Object.keys(model.variants ?? {}).length + ? {} + : customProviderVariants(parsedModel, model.provider?.npm ?? provider.npm, ProviderTransform.variants) const merged = mergeDeep(generated, model.variants ?? {}) // kilocode_change end parsedModel.variants = mapValues( diff --git a/packages/opencode/test/kilocode/custom-provider-variants.test.ts b/packages/opencode/test/kilocode/custom-provider-variants.test.ts index e3a6e6be179..7de3036fd7a 100644 --- a/packages/opencode/test/kilocode/custom-provider-variants.test.ts +++ b/packages/opencode/test/kilocode/custom-provider-variants.test.ts @@ -10,14 +10,13 @@ import { testEffect } from "../lib/effect" const it = testEffect(LayerNode.compile(LayerNode.group([Provider.node, Env.node, Plugin.node]))) it.instance( - "adds fallback efforts to configured reasoning models and merges explicit variants", + "uses configured variants instead of inferred reasoning efforts", () => Effect.gen(function* () { const providers = yield* Provider.use.list() const model = providers[ProviderV2.ID.make("custom")]?.models["qwen-custom"] - expect(Object.keys(model?.variants ?? {})).toEqual(["none", "low", "medium", "xhigh", "max", "custom"]) - expect(model?.variants?.none).toEqual({ reasoningEffort: "none" }) + expect(Object.keys(model?.variants ?? {})).toEqual(["custom"]) expect(model?.variants?.high).toBeUndefined() expect(model?.variants?.custom).toEqual({ reasoningEffort: "custom" }) }), diff --git a/packages/opencode/test/kilocode/provider-reasoning-options.test.ts b/packages/opencode/test/kilocode/provider-reasoning-options.test.ts index 268d27f4399..5987819421d 100644 --- a/packages/opencode/test/kilocode/provider-reasoning-options.test.ts +++ b/packages/opencode/test/kilocode/provider-reasoning-options.test.ts @@ -168,6 +168,18 @@ describe("custom provider fallback reasoning efforts", () => { expect(customProviderVariants(model, model.api.npm, () => generated)).toBe(generated) }) + test("prefers configured variants to inference", () => { + const variants = { custom: { reasoningEffort: "custom" } } + for (const npm of ["@ai-sdk/openai-compatible", "@ai-sdk/openai", "@ai-sdk/anthropic"]) { + const model = mockModel({ api: { id: "custom", url: "https://api.test.com", npm }, variants }) + expect( + customProviderVariants(model, npm, () => { + throw new Error("inference should not run") + }), + ).toBe(variants) + } + }) + test("requires a reasoning model with an explicitly configured supported package", () => { const npm = "@ai-sdk/openai-compatible" const plain = mockModel({ From ed63f985d92f36f7e196d42136103875b71339c0 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Fri, 7 Aug 2026 09:58:35 +0200 Subject: [PATCH 05/34] feat(vscode): link custom providers to config --- .changeset/broad-custom-provider-efforts.md | 2 +- .../tests/unit/open-config-message.test.ts | 14 ++++++++ .../settings/CustomProviderDialog.tsx | 14 ++++++++ .../src/components/settings/Settings.tsx | 30 ++--------------- .../kilo-vscode/webview-ui/src/i18n/ar.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/br.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/bs.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/da.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/de.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/en.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/es.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/fa.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/fr.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/it.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/ja.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/ko.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/nl.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/no.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/pl.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/ru.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/th.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/tr.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/uk.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/zh.ts | 1 + .../kilo-vscode/webview-ui/src/i18n/zht.ts | 1 + .../webview-ui/src/utils/open-config.ts | 32 +++++++++++++++++++ 26 files changed, 84 insertions(+), 29 deletions(-) create mode 100644 packages/kilo-vscode/tests/unit/open-config-message.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/utils/open-config.ts diff --git a/.changeset/broad-custom-provider-efforts.md b/.changeset/broad-custom-provider-efforts.md index 2b275555aa1..c0746956176 100644 --- a/.changeset/broad-custom-provider-efforts.md +++ b/.changeset/broad-custom-provider-efforts.md @@ -3,4 +3,4 @@ "kilo-code": patch --- -Automatically expose broad reasoning effort options for custom provider models instead of requiring manual variant configuration. +Automatically expose broad reasoning effort options for custom provider models and link saved providers to advanced JSON configuration. diff --git a/packages/kilo-vscode/tests/unit/open-config-message.test.ts b/packages/kilo-vscode/tests/unit/open-config-message.test.ts new file mode 100644 index 00000000000..f16f0ee458d --- /dev/null +++ b/packages/kilo-vscode/tests/unit/open-config-message.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "bun:test" +import { configMessage } from "../../webview-ui/src/utils/open-config" + +describe("configMessage", () => { + it("builds a global config request with localized labels", () => { + const message = configMessage("global", (key, params) => `${key}:${params?.scope ?? ""}`) + + expect(message.type).toBe("openConfigFile") + expect(message.scope).toBe("global") + expect(message.labels.scope).toBe("settings.config.scope.global:") + expect(message.labels.title).toBe("settings.config.title:settings.config.scope.global:") + expect(message.labels.openFailed).toBe("settings.config.openFailed:settings.config.scope.global:") + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx index e81910eadf7..c548b06c76d 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx @@ -15,6 +15,7 @@ import { useProvider } from "../../context/provider" import { useVSCode } from "../../context/vscode" import type { ExtensionMessage, ProviderAuthState, ProviderConfig } from "../../types/messages" import { createProviderAction } from "../../utils/provider-action" +import { configMessage } from "../../utils/open-config" import { MASKED_CUSTOM_PROVIDER_KEY, resolveCustomProviderKey } from "../../../../src/shared/custom-provider" import { CUSTOM_PROVIDER_PACKAGE, @@ -563,6 +564,19 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => { {language.t("provider.custom.description.link")} {language.t("provider.custom.description.suffix")} + + +
diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx index 8bd39f14a44..ab8a5e99fba 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx @@ -28,6 +28,7 @@ import SandboxingTab from "./SandboxingTab" import * as Sandboxing from "./sandboxing" import { useServer } from "../../context/server" import type { MigrationSource } from "../../types/messages" +import { configMessage } from "../../utils/open-config" export interface SettingsProps { tab?: string @@ -66,34 +67,7 @@ const Settings: Component = (props) => { } const open = (scope: "local" | "global") => { - const label = - scope === "global" ? language.t("settings.config.scope.global") : language.t("settings.config.scope.local") - vscode.postMessage({ - type: "openConfigFile", - scope, - labels: { - scope: label, - statusLoaded: language.t("settings.config.status.loaded"), - statusLoadedLegacy: language.t("settings.config.status.loadedLegacy"), - statusNotLoaded: language.t("settings.config.status.notLoaded"), - statusCreate: language.t("settings.config.status.create"), - title: language.t("settings.config.title", { scope: label }), - placeholder: language.t("settings.config.placeholder"), - noWorkspace: language.t("settings.config.noWorkspace"), - openFailed: language.t("settings.config.openFailed", { scope: label, message: "{{message}}" }), - sourceXdg: language.t("settings.config.source.xdg"), - sourceHomeKilo: language.t("settings.config.source.homeKilo"), - sourceHomeKilocode: language.t("settings.config.source.homeKilocode"), - sourceHomeOpencode: language.t("settings.config.source.homeOpencode"), - sourceEnvFile: language.t("settings.config.source.envFile"), - sourceEnvDir: language.t("settings.config.source.envDir"), - sourceEnvContent: language.t("settings.config.source.envContent"), - sourceProjectKilo: language.t("settings.config.source.projectKilo"), - sourceProjectRoot: language.t("settings.config.source.projectRoot"), - sourceProjectKilocode: language.t("settings.config.source.projectKilocode"), - sourceProjectOpencode: language.t("settings.config.source.projectOpencode"), - }, - }) + vscode.postMessage(configMessage(scope, language.t)) } // Sync when the parent changes the tab prop (e.g. via navigate message) diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 71ac3559bce..42dde00f517 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -455,6 +455,7 @@ export const dict = { "provider.custom.models.fetch.search": "البحث في النماذج\u2026", "provider.custom.models.fetch.add": "إضافة {{count}} نموذج(نماذج)", "provider.custom.edit.title": "تعديل المزود", + "provider.custom.edit.advanced": "تحرير الإعدادات المتقدمة في ملف إعداد JSON", "provider.custom.headers.label": "الرؤوس (اختياري)", "provider.custom.headers.key.label": "الرأس", "provider.custom.headers.key.placeholder": "Header-Name", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 589a8b38714..42334d5c232 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -466,6 +466,7 @@ export const dict = { "provider.custom.models.fetch.search": "Pesquisar modelos\u2026", "provider.custom.models.fetch.add": "Adicionar {{count}} modelo(s)", "provider.custom.edit.title": "Editar provedor", + "provider.custom.edit.advanced": "Editar configurações avançadas no arquivo de configuração JSON", "provider.custom.headers.label": "Headers (opcional)", "provider.custom.headers.key.label": "Cabeçalho", "provider.custom.headers.key.placeholder": "Header-Name", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index d737c969906..a8f4e547c09 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -509,6 +509,7 @@ export const dict = { "provider.custom.models.fetch.search": "Pretraži modele\u2026", "provider.custom.models.fetch.add": "Dodaj {{count}} model(a)", "provider.custom.edit.title": "Uredi provajdera", + "provider.custom.edit.advanced": "Uredite napredne postavke u JSON konfiguracijskoj datoteci", "provider.custom.headers.label": "Zaglavlja (opcionalno)", "provider.custom.headers.key.label": "Zaglavlje", "provider.custom.headers.key.placeholder": "Header-Name", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index bf71f652cf9..66d4104056d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -506,6 +506,7 @@ export const dict = { "provider.custom.models.fetch.search": "Søg modeller\u2026", "provider.custom.models.fetch.add": "Tilføj {{count}} model(ler)", "provider.custom.edit.title": "Rediger udbyder", + "provider.custom.edit.advanced": "Rediger avancerede indstillinger i JSON-konfigurationsfilen", "provider.custom.headers.label": "Headers (valgfrit)", "provider.custom.headers.key.label": "Header", "provider.custom.headers.key.placeholder": "Header-Name", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 12d30679fac..221b11d3e14 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -517,6 +517,7 @@ export const dict = { "provider.custom.models.fetch.search": "Modelle suchen\u2026", "provider.custom.models.fetch.add": "{{count}} Modell(e) hinzufügen", "provider.custom.edit.title": "Anbieter bearbeiten", + "provider.custom.edit.advanced": "Erweiterte Einstellungen in der JSON-Konfigurationsdatei bearbeiten", "provider.custom.headers.label": "Header (optional)", "provider.custom.headers.key.label": "Kopfzeile", "provider.custom.headers.key.placeholder": "Header-Name", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index c5f19bdb6b0..cbcd4ea4223 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -420,6 +420,7 @@ export const dict = { "provider.custom.models.fetch.search": "Search models\u2026", "provider.custom.models.fetch.add": "Add {{count}} model(s)", "provider.custom.edit.title": "Edit provider", + "provider.custom.edit.advanced": "Edit advanced settings in the JSON config file", "provider.custom.headers.label": "Headers (optional)", "provider.custom.headers.key.label": "Header", "provider.custom.headers.key.placeholder": "Header-Name", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index bee7bb09d13..929494b6b87 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -510,6 +510,7 @@ export const dict = { "provider.custom.models.fetch.search": "Buscar modelos\u2026", "provider.custom.models.fetch.add": "Añadir {{count}} modelo(s)", "provider.custom.edit.title": "Editar proveedor", + "provider.custom.edit.advanced": "Editar la configuración avanzada en el archivo de configuración JSON", "provider.custom.headers.label": "Headers (opcional)", "provider.custom.headers.key.label": "Header", "provider.custom.headers.key.placeholder": "Header-Name", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts index 1d18abd83dc..b18b51b7ae1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -421,6 +421,7 @@ export const dict = { "provider.custom.models.fetch.search": "جستجوی مدل‌ها…", "provider.custom.models.fetch.add": "افزودن {{count}} مدل", "provider.custom.edit.title": "ویرایش ارائه‌دهنده", + "provider.custom.edit.advanced": "ویرایش تنظیمات پیشرفته در فایل پیکربندی JSON", "provider.custom.headers.label": "هدرها (اختیاری)", "provider.custom.headers.key.label": "هدر", "provider.custom.headers.key.placeholder": "Header-Name", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index dcf4a43dc37..f63a277f928 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -510,6 +510,7 @@ export const dict = { "provider.custom.models.fetch.search": "Rechercher des modèles\u2026", "provider.custom.models.fetch.add": "Ajouter {{count}} modèle(s)", "provider.custom.edit.title": "Modifier le fournisseur", + "provider.custom.edit.advanced": "Modifier les paramètres avancés dans le fichier de configuration JSON", "provider.custom.headers.label": "En-têtes (optionnel)", "provider.custom.headers.key.label": "En-tête", "provider.custom.headers.key.placeholder": "Header-Name", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index 84a1a7f4be0..01fa7e835ff 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -330,6 +330,7 @@ export const dict = { "provider.custom.models.fetch.search": "Cerca modelli...", "provider.custom.models.fetch.add": "Aggiungi {{count}} modelli", "provider.custom.edit.title": "Modifica provider", + "provider.custom.edit.advanced": "Modifica le impostazioni avanzate nel file di configurazione JSON", "provider.custom.headers.label": "Header (opzionali)", "provider.custom.headers.key.label": "Header", "provider.custom.headers.key.placeholder": "Nome-Header", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 96f9f032d6d..dffecedbd80 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -502,6 +502,7 @@ export const dict = { "provider.custom.models.fetch.search": "モデルを検索\u2026", "provider.custom.models.fetch.add": "{{count}}個のモデルを追加", "provider.custom.edit.title": "プロバイダーを編集", + "provider.custom.edit.advanced": "JSON 設定ファイルで詳細設定を編集", "provider.custom.headers.label": "ヘッダー(任意)", "provider.custom.headers.key.label": "ヘッダー", "provider.custom.headers.key.placeholder": "Header-Name", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 69cb64653ff..fac9f03cea6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -463,6 +463,7 @@ export const dict = { "provider.custom.models.fetch.search": "모델 검색\u2026", "provider.custom.models.fetch.add": "{{count}}개 모델 추가", "provider.custom.edit.title": "공급자 편집", + "provider.custom.edit.advanced": "JSON 구성 파일에서 고급 설정 편집", "provider.custom.headers.label": "헤더 (선택사항)", "provider.custom.headers.key.label": "헤더", "provider.custom.headers.key.placeholder": "Header-Name", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index ae0a6b22bfe..c1485ff655b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -460,6 +460,7 @@ export const dict = { "provider.custom.models.fetch.search": "Modellen zoeken\u2026", "provider.custom.models.fetch.add": "{{count}} model(len) toevoegen", "provider.custom.edit.title": "Provider bewerken", + "provider.custom.edit.advanced": "Geavanceerde instellingen bewerken in het JSON-configuratiebestand", "provider.custom.headers.label": "Headers (optioneel)", "provider.custom.headers.key.label": "Header", "provider.custom.headers.key.placeholder": "Header-Naam", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 6cb0b4bb955..dd6da3fe63e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -469,6 +469,7 @@ export const dict = { "provider.custom.models.fetch.search": "Søk etter modeller\u2026", "provider.custom.models.fetch.add": "Legg til {{count}} modell(er)", "provider.custom.edit.title": "Rediger leverandør", + "provider.custom.edit.advanced": "Rediger avanserte innstillinger i JSON-konfigurasjonsfilen", "provider.custom.headers.label": "Headere (valgfritt)", "provider.custom.headers.key.label": "Header", "provider.custom.headers.key.placeholder": "Header-Name", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 8e6744d6aa8..2114eb158f6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -465,6 +465,7 @@ export const dict = { "provider.custom.models.fetch.search": "Szukaj modeli\u2026", "provider.custom.models.fetch.add": "Dodaj {{count}} model(i)", "provider.custom.edit.title": "Edytuj dostawcę", + "provider.custom.edit.advanced": "Edytuj ustawienia zaawansowane w pliku konfiguracji JSON", "provider.custom.headers.label": "Nagłówki (opcjonalnie)", "provider.custom.headers.key.label": "Nagłówek", "provider.custom.headers.key.placeholder": "Header-Name", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 91b22b70430..4e20af74782 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -503,6 +503,7 @@ export const dict = { "provider.custom.models.fetch.search": "Поиск моделей\u2026", "provider.custom.models.fetch.add": "Добавить {{count}} модель(ей)", "provider.custom.edit.title": "Редактировать провайдера", + "provider.custom.edit.advanced": "Изменить расширенные настройки в файле конфигурации JSON", "provider.custom.headers.label": "Заголовки (необязательно)", "provider.custom.headers.key.label": "Заголовок", "provider.custom.headers.key.placeholder": "Header-Name", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 764eef584ee..66924828bcb 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -500,6 +500,7 @@ export const dict = { "provider.custom.models.fetch.search": "ค้นหาโมเดล\u2026", "provider.custom.models.fetch.add": "เพิ่ม {{count}} โมเดล", "provider.custom.edit.title": "แก้ไขผู้ให้บริการ", + "provider.custom.edit.advanced": "แก้ไขการตั้งค่าขั้นสูงในไฟล์การกำหนดค่า JSON", "provider.custom.headers.label": "ส่วนหัว (ไม่จำเป็น)", "provider.custom.headers.key.label": "ส่วนหัว", "provider.custom.headers.key.placeholder": "Header-Name", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 9aa92a001be..8ee41b0e18f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -455,6 +455,7 @@ export const dict = { "provider.custom.models.fetch.search": "Model ara\u2026", "provider.custom.models.fetch.add": "{{count}} model ekle", "provider.custom.edit.title": "Sağlayıcıyı düzenle", + "provider.custom.edit.advanced": "JSON yapılandırma dosyasında gelişmiş ayarları düzenle", "provider.custom.headers.label": "Başlıklar (isteğe bağlı)", "provider.custom.headers.key.label": "Başlık", "provider.custom.headers.key.placeholder": "Başlık-Adı", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 9acb616a6e4..12944f68a4b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -458,6 +458,7 @@ export const dict = { "provider.custom.models.fetch.search": "Пошук моделей\u2026", "provider.custom.models.fetch.add": "Додати {{count}} моделей", "provider.custom.edit.title": "Редагувати провайдера", + "provider.custom.edit.advanced": "Редагувати розширені налаштування у файлі конфігурації JSON", "provider.custom.headers.label": "Заголовки (необов'язково)", "provider.custom.headers.key.label": "Заголовок", "provider.custom.headers.key.placeholder": "Назва-заголовка", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 1d48e7777b4..78d21c59126 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -485,6 +485,7 @@ export const dict = { "provider.custom.models.fetch.search": "搜索模型\u2026", "provider.custom.models.fetch.add": "添加 {{count}} 个模型", "provider.custom.edit.title": "编辑提供商", + "provider.custom.edit.advanced": "在 JSON 配置文件中编辑高级设置", "provider.custom.headers.label": "请求头(可选)", "provider.custom.headers.key.label": "请求头", "provider.custom.headers.key.placeholder": "Header-Name", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 97cdf54edf0..1414c7f47d5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -445,6 +445,7 @@ export const dict = { "provider.custom.models.fetch.search": "搜尋模型\u2026", "provider.custom.models.fetch.add": "新增 {{count}} 個模型", "provider.custom.edit.title": "編輯供應商", + "provider.custom.edit.advanced": "在 JSON 設定檔中編輯進階設定", "provider.custom.headers.label": "標頭(選填)", "provider.custom.headers.key.label": "標頭", "provider.custom.headers.key.placeholder": "Header-Name", diff --git a/packages/kilo-vscode/webview-ui/src/utils/open-config.ts b/packages/kilo-vscode/webview-ui/src/utils/open-config.ts new file mode 100644 index 00000000000..9ae1ed38393 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/utils/open-config.ts @@ -0,0 +1,32 @@ +import type { LanguageContextValue } from "../context/language" +import type { OpenConfigFileRequest } from "../types/messages" + +export function configMessage(scope: "local" | "global", t: LanguageContextValue["t"]): OpenConfigFileRequest { + const label = t(scope === "global" ? "settings.config.scope.global" : "settings.config.scope.local") + return { + type: "openConfigFile", + scope, + labels: { + scope: label, + statusLoaded: t("settings.config.status.loaded"), + statusLoadedLegacy: t("settings.config.status.loadedLegacy"), + statusNotLoaded: t("settings.config.status.notLoaded"), + statusCreate: t("settings.config.status.create"), + title: t("settings.config.title", { scope: label }), + placeholder: t("settings.config.placeholder"), + noWorkspace: t("settings.config.noWorkspace"), + openFailed: t("settings.config.openFailed", { scope: label, message: "{{message}}" }), + sourceXdg: t("settings.config.source.xdg"), + sourceHomeKilo: t("settings.config.source.homeKilo"), + sourceHomeKilocode: t("settings.config.source.homeKilocode"), + sourceHomeOpencode: t("settings.config.source.homeOpencode"), + sourceEnvFile: t("settings.config.source.envFile"), + sourceEnvDir: t("settings.config.source.envDir"), + sourceEnvContent: t("settings.config.source.envContent"), + sourceProjectKilo: t("settings.config.source.projectKilo"), + sourceProjectRoot: t("settings.config.source.projectRoot"), + sourceProjectKilocode: t("settings.config.source.projectKilocode"), + sourceProjectOpencode: t("settings.config.source.projectOpencode"), + }, + } +} From ead46f43415a121bacc28780bc47fe43b1b736ba Mon Sep 17 00:00:00 2001 From: niefangxin_yfmx Date: Fri, 7 Aug 2026 16:57:47 +0800 Subject: [PATCH 06/34] fix(ui): make Mermaid Copy PNG/SVG put images on the clipboard Copy PNG called fetch() on a data URL, which VS Code webview CSP rejects, so the copy silently did nothing. Decode the data URL locally instead. Copy SVG used clipboard.writeText() with SVG markup, so paste targets got source text instead of an image. Write SVG/PNG/text representations when supported, and fall back to PNG on hosts that reject multi-type items. --- .changeset/mermaid-copy-clipboard-images.md | 5 +++ .../src/kilocode/markdown-mermaid-actions.tsx | 13 ++++-- .../markdown-mermaid-data-url.test.ts | 24 ++++++++++ .../src/kilocode/markdown-mermaid-data-url.ts | 12 +++++ packages/ui/src/kilocode/markdown-mermaid.ts | 44 ++++++++++++++++--- 5 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 .changeset/mermaid-copy-clipboard-images.md create mode 100644 packages/ui/src/kilocode/markdown-mermaid-data-url.test.ts create mode 100644 packages/ui/src/kilocode/markdown-mermaid-data-url.ts diff --git a/.changeset/mermaid-copy-clipboard-images.md b/.changeset/mermaid-copy-clipboard-images.md new file mode 100644 index 00000000000..c9c0a405f9c --- /dev/null +++ b/.changeset/mermaid-copy-clipboard-images.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix Mermaid Copy PNG/SVG in VS Code webviews so they put images on the clipboard instead of failing or copying SVG markup. diff --git a/packages/ui/src/kilocode/markdown-mermaid-actions.tsx b/packages/ui/src/kilocode/markdown-mermaid-actions.tsx index 4f5756dbe1b..26fe5e639df 100644 --- a/packages/ui/src/kilocode/markdown-mermaid-actions.tsx +++ b/packages/ui/src/kilocode/markdown-mermaid-actions.tsx @@ -49,10 +49,15 @@ function Item(props: { label: string; onSelect: () => void }) { export function MermaidActions(props: Props) { const [copied, setCopied] = createSignal(false) const copy = (run: () => Promise) => { - void run().then(() => { - setCopied(true) - setTimeout(() => setCopied(false), 1500) - }) + void run() + .then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 1500) + }) + .catch((err) => { + // Avoid unhandledrejection; Copy PNG used to fail silently under webview CSP. + console.warn("Mermaid copy failed", err) + }) } return ( diff --git a/packages/ui/src/kilocode/markdown-mermaid-data-url.test.ts b/packages/ui/src/kilocode/markdown-mermaid-data-url.test.ts new file mode 100644 index 00000000000..592a53213bc --- /dev/null +++ b/packages/ui/src/kilocode/markdown-mermaid-data-url.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test" +import { dataUrlToBlob } from "./markdown-mermaid-data-url" + +describe("dataUrlToBlob", () => { + test("decodes base64 payload and preserves mime type", async () => { + const bytes = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]) + const dataUrl = `data:image/png;base64,${Buffer.from(bytes).toString("base64")}` + const blob = dataUrlToBlob(dataUrl) + + expect(blob.type).toBe("image/png") + expect(blob.size).toBe(bytes.length) + expect(new Uint8Array(await blob.arrayBuffer())).toEqual(bytes) + }) + + test("defaults mime when the data URL omits it", async () => { + const blob = dataUrlToBlob(`data:;base64,${Buffer.from("hi").toString("base64")}`) + expect(blob.type).toBe("application/octet-stream") + expect(await blob.text()).toBe("hi") + }) + + test("rejects data URLs without a comma separator", () => { + expect(() => dataUrlToBlob("data:image/png;base64")).toThrow("Unable to export Mermaid diagram.") + }) +}) diff --git a/packages/ui/src/kilocode/markdown-mermaid-data-url.ts b/packages/ui/src/kilocode/markdown-mermaid-data-url.ts new file mode 100644 index 00000000000..d9eda408f28 --- /dev/null +++ b/packages/ui/src/kilocode/markdown-mermaid-data-url.ts @@ -0,0 +1,12 @@ +// VS Code webview CSP blocks fetch(data:...); decode locally for clipboard/export helpers. +export function dataUrlToBlob(dataUrl: string): Blob { + const comma = dataUrl.indexOf(",") + if (comma < 0) throw new Error("Unable to export Mermaid diagram.") + const header = dataUrl.slice(0, comma) + const data = dataUrl.slice(comma + 1) + const mime = /^data:([^;,]*)/.exec(header)?.[1] || "application/octet-stream" + const binary = atob(data) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + return new Blob([bytes], { type: mime }) +} diff --git a/packages/ui/src/kilocode/markdown-mermaid.ts b/packages/ui/src/kilocode/markdown-mermaid.ts index 33f3e091783..0edeef600d5 100644 --- a/packages/ui/src/kilocode/markdown-mermaid.ts +++ b/packages/ui/src/kilocode/markdown-mermaid.ts @@ -1,6 +1,7 @@ import DOMPurify from "dompurify" import { fnv1a } from "../context/marked" import { mountMermaidActions } from "./markdown-mermaid-actions" +import { dataUrlToBlob } from "./markdown-mermaid-data-url" // DOMPurify >= 3.1.7 dropped foreignObject from the default HTML integration // points, which caused the inner
/ /

labels Mermaid renders @@ -303,14 +304,43 @@ async function copyText(text: string) { await navigator.clipboard.writeText(text) } -async function copyPng(svg: SVGSVGElement) { - const url = await png(svg) - const blob = await (await fetch(url)).blob() +function supportsClipboardType(type: string) { + return typeof ClipboardItem !== "undefined" && "supports" in ClipboardItem && ClipboardItem.supports(type) +} + +async function writeClipboard(items: Record) { if (typeof ClipboardItem === "undefined") { - await navigator.clipboard.writeText(serialize(svg)) - return + throw new Error("ClipboardItem is unavailable.") } - await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]) + await navigator.clipboard.write([new ClipboardItem(items)]) +} + +async function writePngClipboard(svg: SVGSVGElement) { + await writeClipboard({ "image/png": dataUrlToBlob(await png(svg)) }) +} + +async function copySvg(svg: SVGSVGElement) { + const markup = serialize(svg) + // Prefer a rich ClipboardItem so paste targets can pick SVG, PNG, or text. + // Previous writeText(serialize(svg)) always pasted markup instead of an image. + const pngBlob = dataUrlToBlob(await png(svg)) + const items: Record = { + "image/png": pngBlob, + "text/plain": new Blob([markup], { type: "text/plain" }), + } + if (supportsClipboardType("image/svg+xml")) { + items["image/svg+xml"] = new Blob([markup], { type: "image/svg+xml" }) + } + try { + await writeClipboard(items) + } catch { + // Some hosts reject multi-type items; keep an image on the clipboard. + await writeClipboard({ "image/png": pngBlob }) + } +} + +async function copyPng(svg: SVGSVGElement) { + await writePngClipboard(svg) } function renderActions(el: HTMLDivElement, pre: HTMLPreElement, source: string, labels: MermaidLabels) { @@ -329,7 +359,7 @@ function renderActions(el: HTMLDivElement, pre: HTMLPreElement, source: string, mountMermaidActions(el, { labels, onCopySource: () => copyText(sourceText), - onCopySvg: () => copyText(sourceSvg()), + onCopySvg: () => copySvg(svg), onCopyPng: () => copyPng(svg), onDownloadSvg: () => save(sourceSvgUrl(), "mermaid-diagram.svg"), onDownloadPng: async () => save(await png(svg), "mermaid-diagram.png"), From d047224302041a5388f9ad1a04ce3a7f2e652581 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Fri, 7 Aug 2026 13:56:04 +0200 Subject: [PATCH 07/34] fix(vscode): show approval reason for outside-workspace reads and writes --- .../show-outside-workspace-approval-reason.md | 5 +++ .../kilo-ui/src/components/message-part.tsx | 7 ++-- .../src/components/tool-approval.test.ts | 12 +++++++ .../kilo-ui/src/components/tool-approval.tsx | 7 ++++ .../kilo-vscode/webview-ui/src/i18n/en.ts | 1 + .../src/kilocode/permission/provenance.ts | 20 +++++++++-- packages/opencode/src/session/tools.ts | 23 +++++++----- .../kilocode/permission/provenance.test.ts | 36 +++++++++++++++++++ 8 files changed, 99 insertions(+), 12 deletions(-) create mode 100644 .changeset/show-outside-workspace-approval-reason.md diff --git a/.changeset/show-outside-workspace-approval-reason.md b/.changeset/show-outside-workspace-approval-reason.md new file mode 100644 index 00000000000..1475ae34cdf --- /dev/null +++ b/.changeset/show-outside-workspace-approval-reason.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show the permission approval reason for reads and writes outside the workspace, matching other tools diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 4281174d125..b22fed5b0a6 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -48,7 +48,7 @@ import { checksum } from "@opencode-ai/core/util/encode" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" import { TextShimmer } from "@opencode-ai/ui/text-shimmer" -import { ToolApprovalProvider, resolveToolApproval } from "./tool-approval" +import { ToolApprovalProvider, resolveToolApproval, useToolApproval } from "./tool-approval" export { ToolApprovalProvider, resolveToolApproval } from "./tool-approval" import { GrowBox } from "./grow-box" import { COLLAPSIBLE_SPRING } from "./motion" @@ -1888,10 +1888,13 @@ ToolRegistry.register({ const pending = createMemo(() => busy(props.status)) const images = createMemo(() => (props.attachments ?? []).filter((f) => f.mime.startsWith("image/") && f.url)) const preview = (url: string, alt?: string) => dialog.show(() => ) + // Read is high-frequency and low-risk, so details stay hidden unless the target was outside + // the workspace, in which case the approval reason explains what looks like an "agent escape". + const approval = useToolApproval() return ( <> { expect(out?.source).toBe("ui.approval.source.agent(agent=code)") expect(out?.rule).toBeUndefined() }) + + test("adds the outsideWorkspace text when the approval was marked outside the workspace", () => { + const approval = { source: "agent" as const, agent: "code", outsideWorkspace: true } + const out = resolveToolApproval({ approval }, t) + expect(out?.outsideWorkspace).toBe("ui.approval.outsideWorkspace") + }) + + test("omits the outsideWorkspace text for an ordinary in-workspace approval", () => { + const approval = { source: "agent" as const, agent: "code" } + const out = resolveToolApproval({ approval }, t) + expect(out?.outsideWorkspace).toBeUndefined() + }) }) diff --git a/packages/kilo-ui/src/components/tool-approval.tsx b/packages/kilo-ui/src/components/tool-approval.tsx index d555e105df3..1bb2b9f7598 100644 --- a/packages/kilo-ui/src/components/tool-approval.tsx +++ b/packages/kilo-ui/src/components/tool-approval.tsx @@ -11,6 +11,8 @@ export type ToolApproval = { source: "agent" | "global" | "project" | "yolo" | "session" | "manual" | "default" agent?: string rule?: { permission: string; pattern: string; action: string } + /** True when the tool call's target path was outside the workspace/worktree. */ + outsideWorkspace?: boolean } /** Pre-resolved, localized text plus the raw approval, supplied by the caller. */ @@ -19,6 +21,7 @@ export type ToolApprovalDisplay = { decision: string source?: string rule?: string + outsideWorkspace?: string } const SOURCE_KEYS = ["agent", "global", "project", "yolo", "session", "manual", "default"] as const @@ -71,6 +74,7 @@ export function resolveToolApproval( decision: approval.source === "manual" ? t("ui.approval.manual") : t("ui.approval.auto"), source: sourceText(), rule: ruleText, + outsideWorkspace: approval.outsideWorkspace ? t("ui.approval.outsideWorkspace") : undefined, } } @@ -84,6 +88,9 @@ export function ToolApprovalLine(props: { display: ToolApprovalDisplay }) { {(text) => {text()}} {(text) => {text()}} + + {(text) => {text()}} +

) } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 6a53383af8f..7ba0c65aa8a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -302,6 +302,7 @@ export const dict = { "ui.approval.source.yolo": "by auto-approve (YOLO) mode", "ui.approval.source.session": "by a session auto-approve rule", "ui.approval.source.default": "by default", + "ui.approval.outsideWorkspace": "path is outside your workspace", "session.tab.review": "Review", "session.review.filesChanged": "{{count}} Files Changed", diff --git a/packages/opencode/src/kilocode/permission/provenance.ts b/packages/opencode/src/kilocode/permission/provenance.ts index 73f7fec4e0f..896db47f1f9 100644 --- a/packages/opencode/src/kilocode/permission/provenance.ts +++ b/packages/opencode/src/kilocode/permission/provenance.ts @@ -27,6 +27,13 @@ export namespace PermissionProvenance { agent?: string /** The winning rule, omitted for manual replies and the ask fallback. */ rule?: { permission: string; pattern: string; action: Permission.Action } + /** True when the ask's target path was outside the workspace/worktree (an `external_directory` ask). */ + outsideWorkspace?: boolean + } + + /** Tag an approval as outside-workspace when it answers an `external_directory` ask. */ + export function tagOutsideWorkspace(approval: Approval, permission: string): Approval { + return permission === "external_directory" ? { ...approval, outsideWorkspace: true } : approval } export type Scope = "global" | "local" @@ -71,13 +78,22 @@ export namespace PermissionProvenance { * The approval is written once during `ask()`, but tools freely overwrite `state.metadata` * during execution and on completion. Carry the prior `approval` onto the replacement unless * the replacement sets its own. + * + * A file tool that crosses the workspace boundary issues *two* asks for one call: the generic + * `external_directory` ask first, then its own `read`/`write`/`edit` ask. Both write `approval` + * metadata, so the second ask's `outsideWorkspace` marker would otherwise clobber the first's + * even though `"approval" in next` is true. Merge that marker forward so it survives. */ export function carryApproval( prev: Record | undefined, next: Record | undefined, ) { - if (!next || !prev?.approval || "approval" in next) return next - return { ...next, approval: prev.approval } + if (!next) return next + const prior = prev?.approval as Approval | undefined + if (!("approval" in next)) return prior ? { ...next, approval: prior } : next + const current = next.approval as Approval | undefined + if (!prior?.outsideWorkspace || !current || current.outsideWorkspace) return next + return { ...next, approval: { ...current, outsideWorkspace: true } } } /** diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 2d7f45fbf2a..fa2a827ebd8 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -97,18 +97,25 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { }, }).pipe( // record why the call was allowed onto the tool part, then discard the outcome for the tool-facing ask - Effect.tap((approval) => input.processor.metadata(options.toolCallId, { metadata: { approval } })), + Effect.tap((approval) => + input.processor.metadata(options.toolCallId, { + metadata: { approval: PermissionProvenance.tagOutsideWorkspace(approval, req.permission) }, + }), + ), // record why the call was denied too, so JSON exports and clients can explain the denial Effect.tapErrorTag("PermissionDeniedError", (err) => input.processor.metadata(options.toolCallId, { metadata: { - approval: PermissionProvenance.classifyDenial({ - ruleset: err.ruleset, - permission: req.permission, - patterns: req.patterns, - agent: input.agent.name, - origins: permissionOrigins, - }), + approval: PermissionProvenance.tagOutsideWorkspace( + PermissionProvenance.classifyDenial({ + ruleset: err.ruleset, + permission: req.permission, + patterns: req.patterns, + agent: input.agent.name, + origins: permissionOrigins, + }), + req.permission, + ), }, }), ), diff --git a/packages/opencode/test/kilocode/permission/provenance.test.ts b/packages/opencode/test/kilocode/permission/provenance.test.ts index ad179e7e719..816d577d995 100644 --- a/packages/opencode/test/kilocode/permission/provenance.test.ts +++ b/packages/opencode/test/kilocode/permission/provenance.test.ts @@ -121,6 +121,42 @@ describe("PermissionProvenance.carryApproval", () => { test("returns the replacement as-is when it is undefined", () => { expect(PermissionProvenance.carryApproval({ approval }, undefined)).toBeUndefined() }) + + test("merges outsideWorkspace onto a replacement's own approval instead of clobbering it", () => { + // A file tool crossing the workspace boundary asks twice: external_directory first, then its + // own read/write/edit ask. The second ask's approval must not lose the outsideWorkspace marker. + const outside = { source: "manual" as const, outsideWorkspace: true } + const next = { approval: { source: "agent" as const, agent: "build" } } + expect(PermissionProvenance.carryApproval({ approval: outside }, next)).toEqual({ + approval: { source: "agent", agent: "build", outsideWorkspace: true }, + }) + }) + + test("does not add outsideWorkspace when the prior approval was not outside the workspace", () => { + const next = { approval: { source: "agent" as const, agent: "build" } } + expect(PermissionProvenance.carryApproval({ approval }, next)).toBe(next) + }) + + test("leaves a replacement's own outsideWorkspace marker untouched", () => { + const outside = { source: "manual" as const, outsideWorkspace: true } + const next = { approval: { source: "agent" as const, agent: "build", outsideWorkspace: true } } + expect(PermissionProvenance.carryApproval({ approval: outside }, next)).toBe(next) + }) +}) + +describe("PermissionProvenance.tagOutsideWorkspace", () => { + test("marks an external_directory approval as outsideWorkspace", () => { + const approval = { source: "manual" as const } + expect(PermissionProvenance.tagOutsideWorkspace(approval, "external_directory")).toEqual({ + source: "manual", + outsideWorkspace: true, + }) + }) + + test("leaves other permissions' approvals untouched", () => { + const approval = { source: "manual" as const } + expect(PermissionProvenance.tagOutsideWorkspace(approval, "read")).toBe(approval) + }) }) describe("askPermission returns provenance", () => { From 0c9b640331d05012bad4b98527bcbae86b3d80f2 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Fri, 7 Aug 2026 14:53:39 +0200 Subject: [PATCH 08/34] fix(vscode): add missing outsideWorkspace i18n key to all locales --- packages/kilo-vscode/webview-ui/src/i18n/ar.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/br.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/bs.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/da.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/de.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/es.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/fa.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/fr.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/it.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/ja.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/ko.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/nl.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/no.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/pl.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/ru.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/th.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/tr.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/uk.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/zh.ts | 1 + packages/kilo-vscode/webview-ui/src/i18n/zht.ts | 1 + 20 files changed, 20 insertions(+) diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 86f2eef2790..f7c71435f13 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -297,6 +297,7 @@ export const dict = { "ui.approval.source.yolo": "بواسطة وضع الموافقة التلقائية (YOLO)", "ui.approval.source.session": "بواسطة قاعدة موافقة تلقائية للجلسة", "ui.approval.source.default": "افتراضيًا", + "ui.approval.outsideWorkspace": "path is outside your workspace", "session.tab.review": "مراجعة", "session.review.filesChanged": "تم تغيير {{count}} ملفات", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index d29e57f2ce9..d8c60157218 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -307,6 +307,7 @@ export const dict = { "ui.approval.source.yolo": "pelo modo de aprovação automática (YOLO)", "ui.approval.source.session": "por uma regra de aprovação automática da sessão", "ui.approval.source.default": "por padrão", + "ui.approval.outsideWorkspace": "path is outside your workspace", "session.tab.review": "Revisão", "session.review.filesChanged": "{{count}} Arquivos Alterados", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index cf67d75e527..f7e39f6bfa0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -305,6 +305,7 @@ export const dict = { "ui.approval.source.yolo": "režimom automatskog odobravanja (YOLO)", "ui.approval.source.session": "pravilom automatskog odobravanja sesije", "ui.approval.source.default": "podrazumevano", + "ui.approval.outsideWorkspace": "path is outside your workspace", "session.tab.review": "Pregled", "session.review.filesChanged": "Izmijenjeno {{count}} datoteka", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index e432d839775..97f238fcf0d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -304,6 +304,7 @@ export const dict = { "ui.approval.source.yolo": "af automatisk godkendelse (YOLO)", "ui.approval.source.session": "af en session-autogodkendelsesregel", "ui.approval.source.default": "som standard", + "ui.approval.outsideWorkspace": "path is outside your workspace", "session.tab.review": "Gennemgang", "session.review.filesChanged": "{{count}} Filer ændret", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 3d7058a7ad5..dd334cb8b6a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -313,6 +313,7 @@ export const dict = { "ui.approval.source.yolo": "durch den Auto-Genehmigungsmodus (YOLO)", "ui.approval.source.session": "durch eine Sitzungs-Auto-Genehmigungsregel", "ui.approval.source.default": "standardmäßig", + "ui.approval.outsideWorkspace": "path is outside your workspace", "session.tab.review": "Überprüfung", "session.review.filesChanged": "{{count}} Dateien geändert", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 3af575f0c5e..556e4d950bb 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -308,6 +308,7 @@ export const dict = { "ui.approval.source.yolo": "por el modo de aprobación automática (YOLO)", "ui.approval.source.session": "por una regla de aprobación automática de sesión", "ui.approval.source.default": "de forma predeterminada", + "ui.approval.outsideWorkspace": "path is outside your workspace", "session.tab.review": "Revisión", "session.review.filesChanged": "{{count}} Archivos Cambiados", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts index 97f0b6b457a..6e0b77e3c3f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -302,6 +302,7 @@ export const dict = { "ui.approval.source.yolo": "توسط حالت تأیید خودکار (YOLO)", "ui.approval.source.session": "توسط قانون تأیید خودکار جلسه", "ui.approval.source.default": "به‌طور پیش‌فرض", + "ui.approval.outsideWorkspace": "path is outside your workspace", "session.tab.review": "بررسی", "session.review.filesChanged": "{{count}} فایل تغییر یافته", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index be9791bc667..3349663f683 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -307,6 +307,7 @@ export const dict = { "ui.approval.source.yolo": "par le mode d'approbation automatique (YOLO)", "ui.approval.source.session": "par une règle d'approbation automatique de session", "ui.approval.source.default": "par défaut", + "ui.approval.outsideWorkspace": "path is outside your workspace", "session.tab.review": "Revue", "session.review.filesChanged": "{{count}} fichiers modifiés", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index f1d977e1883..5600b4b533f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -219,6 +219,7 @@ export const dict = { "ui.approval.source.yolo": "dalla modalità di approvazione automatica (YOLO)", "ui.approval.source.session": "da una regola di approvazione automatica della sessione", "ui.approval.source.default": "per impostazione predefinita", + "ui.approval.outsideWorkspace": "path is outside your workspace", "session.tab.review": "Revisione", "session.review.filesChanged": "{{count}} file modificati", "session.review.loadingChanges": "Caricamento modifiche...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 279600efc2b..7f38cab2ecb 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -304,6 +304,7 @@ export const dict = { "ui.approval.source.yolo": "自動承認(YOLO)モードによって", "ui.approval.source.session": "セッションの自動承認ルールによって", "ui.approval.source.default": "デフォルトで", + "ui.approval.outsideWorkspace": "path is outside your workspace", "session.tab.review": "レビュー", "session.review.filesChanged": "{{count}} ファイル変更", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index bfc1ae50aeb..6c88e3d17ac 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -305,6 +305,7 @@ export const dict = { "ui.approval.source.yolo": "자동 승인(YOLO) 모드에 의해", "ui.approval.source.session": "세션 자동 승인 규칙에 의해", "ui.approval.source.default": "기본값으로", + "ui.approval.outsideWorkspace": "path is outside your workspace", "session.tab.review": "검토", "session.review.filesChanged": "{{count}}개 파일 변경됨", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index c3ad428f564..52ebf8e8787 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -308,6 +308,7 @@ export const dict = { "ui.approval.source.yolo": "door de automatische goedkeuringsmodus (YOLO)", "ui.approval.source.session": "door een sessie-automatische-goedkeuringsregel", "ui.approval.source.default": "standaard", + "ui.approval.outsideWorkspace": "path is outside your workspace", "session.tab.review": "Beoordelen", "session.review.filesChanged": "{{count}} bestanden gewijzigd", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 519b8ebc2cd..a0e4809dcd7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -311,6 +311,7 @@ export const dict = { "ui.approval.source.yolo": "av automatisk godkjenning (YOLO)", "ui.approval.source.session": "av en økt-autogodkjenningsregel", "ui.approval.source.default": "som standard", + "ui.approval.outsideWorkspace": "path is outside your workspace", "session.tab.review": "Gjennomgang", "session.review.filesChanged": "{{count}} filer endret", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index dc7f924e6ac..8f655adac46 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -305,6 +305,7 @@ export const dict = { "ui.approval.source.yolo": "przez tryb automatycznego zatwierdzania (YOLO)", "ui.approval.source.session": "przez regułę automatycznego zatwierdzania sesji", "ui.approval.source.default": "domyślnie", + "ui.approval.outsideWorkspace": "path is outside your workspace", "session.tab.review": "Przegląd", "session.review.filesChanged": "Zmieniono {{count}} plików", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 0ecea516048..9731d5a1523 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -303,6 +303,7 @@ export const dict = { "ui.approval.source.yolo": "режимом автоодобрения (YOLO)", "ui.approval.source.session": "правилом автоодобрения сессии", "ui.approval.source.default": "по умолчанию", + "ui.approval.outsideWorkspace": "path is outside your workspace", "session.tab.review": "Обзор", "session.review.filesChanged": "{{count}} файлов изменено", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 1f2cc4ac422..2d6f0cac2ba 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -302,6 +302,7 @@ export const dict = { "ui.approval.source.yolo": "โดยโหมดอนุมัติอัตโนมัติ (YOLO)", "ui.approval.source.session": "โดยกฎอนุมัติอัตโนมัติของเซสชัน", "ui.approval.source.default": "ตามค่าเริ่มต้น", + "ui.approval.outsideWorkspace": "path is outside your workspace", "session.tab.review": "ตรวจสอบ", "session.review.filesChanged": "{{count}} ไฟล์ที่เปลี่ยนแปลง", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index e5748071b2e..0671e4a0fa8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -303,6 +303,7 @@ export const dict = { "ui.approval.source.yolo": "otomatik onay (YOLO) modu tarafından", "ui.approval.source.session": "bir oturum otomatik onay kuralı tarafından", "ui.approval.source.default": "varsayılan olarak", + "ui.approval.outsideWorkspace": "path is outside your workspace", "session.tab.review": "İnceleme", "session.review.filesChanged": "{{count}} Dosya Değişti", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 183912721fa..a763b3b8d32 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -307,6 +307,7 @@ export const dict = { "ui.approval.source.yolo": "режимом автосхвалення (YOLO)", "ui.approval.source.session": "правилом автосхвалення сесії", "ui.approval.source.default": "за замовчуванням", + "ui.approval.outsideWorkspace": "path is outside your workspace", "session.tab.review": "Огляд", "session.review.filesChanged": "{{count}} файлів змінено", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index dd35db0e2d6..f2037d94047 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -292,6 +292,7 @@ export const dict = { "ui.approval.source.yolo": "由自动批准(YOLO)模式", "ui.approval.source.session": "由会话自动批准规则", "ui.approval.source.default": "默认", + "ui.approval.outsideWorkspace": "path is outside your workspace", "session.tab.review": "审查", "session.review.filesChanged": "{{count}} 个文件变更", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index aabed9a3e8d..d78c0885301 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -290,6 +290,7 @@ export const dict = { "ui.approval.source.yolo": "由自動核准(YOLO)模式", "ui.approval.source.session": "由工作階段自動核准規則", "ui.approval.source.default": "預設", + "ui.approval.outsideWorkspace": "path is outside your workspace", "session.tab.review": "審查", "session.review.filesChanged": "{{count}} 個檔案變更", From d1630fcbded568a60c5abc956381edb7ae410c17 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Fri, 7 Aug 2026 14:58:16 +0200 Subject: [PATCH 09/34] fix(vscode): translate outsideWorkspace approval string for all locales --- packages/kilo-vscode/webview-ui/src/i18n/ar.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/br.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/bs.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/da.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/de.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/es.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/fa.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/fr.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/it.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/ja.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/ko.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/nl.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/no.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/pl.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/ru.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/th.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/tr.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/uk.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/zh.ts | 2 +- packages/kilo-vscode/webview-ui/src/i18n/zht.ts | 2 +- 20 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index f7c71435f13..9d59467f9ed 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -297,7 +297,7 @@ export const dict = { "ui.approval.source.yolo": "بواسطة وضع الموافقة التلقائية (YOLO)", "ui.approval.source.session": "بواسطة قاعدة موافقة تلقائية للجلسة", "ui.approval.source.default": "افتراضيًا", - "ui.approval.outsideWorkspace": "path is outside your workspace", + "ui.approval.outsideWorkspace": "المسار خارج مساحة العمل الخاصة بك", "session.tab.review": "مراجعة", "session.review.filesChanged": "تم تغيير {{count}} ملفات", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index d8c60157218..4ea354d7d1b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -307,7 +307,7 @@ export const dict = { "ui.approval.source.yolo": "pelo modo de aprovação automática (YOLO)", "ui.approval.source.session": "por uma regra de aprovação automática da sessão", "ui.approval.source.default": "por padrão", - "ui.approval.outsideWorkspace": "path is outside your workspace", + "ui.approval.outsideWorkspace": "o caminho está fora do seu espaço de trabalho", "session.tab.review": "Revisão", "session.review.filesChanged": "{{count}} Arquivos Alterados", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index f7e39f6bfa0..953b1378320 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -305,7 +305,7 @@ export const dict = { "ui.approval.source.yolo": "režimom automatskog odobravanja (YOLO)", "ui.approval.source.session": "pravilom automatskog odobravanja sesije", "ui.approval.source.default": "podrazumevano", - "ui.approval.outsideWorkspace": "path is outside your workspace", + "ui.approval.outsideWorkspace": "putanja je izvan vašeg radnog prostora", "session.tab.review": "Pregled", "session.review.filesChanged": "Izmijenjeno {{count}} datoteka", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 97f238fcf0d..d0433fdff8e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -304,7 +304,7 @@ export const dict = { "ui.approval.source.yolo": "af automatisk godkendelse (YOLO)", "ui.approval.source.session": "af en session-autogodkendelsesregel", "ui.approval.source.default": "som standard", - "ui.approval.outsideWorkspace": "path is outside your workspace", + "ui.approval.outsideWorkspace": "stien er uden for dit arbejdsområde", "session.tab.review": "Gennemgang", "session.review.filesChanged": "{{count}} Filer ændret", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index dd334cb8b6a..67ca6ef3bcb 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -313,7 +313,7 @@ export const dict = { "ui.approval.source.yolo": "durch den Auto-Genehmigungsmodus (YOLO)", "ui.approval.source.session": "durch eine Sitzungs-Auto-Genehmigungsregel", "ui.approval.source.default": "standardmäßig", - "ui.approval.outsideWorkspace": "path is outside your workspace", + "ui.approval.outsideWorkspace": "der Pfad liegt außerhalb deines Arbeitsbereichs", "session.tab.review": "Überprüfung", "session.review.filesChanged": "{{count}} Dateien geändert", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 556e4d950bb..a8de2c15dab 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -308,7 +308,7 @@ export const dict = { "ui.approval.source.yolo": "por el modo de aprobación automática (YOLO)", "ui.approval.source.session": "por una regla de aprobación automática de sesión", "ui.approval.source.default": "de forma predeterminada", - "ui.approval.outsideWorkspace": "path is outside your workspace", + "ui.approval.outsideWorkspace": "la ruta está fuera de tu espacio de trabajo", "session.tab.review": "Revisión", "session.review.filesChanged": "{{count}} Archivos Cambiados", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts index 6e0b77e3c3f..45974a628f5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -302,7 +302,7 @@ export const dict = { "ui.approval.source.yolo": "توسط حالت تأیید خودکار (YOLO)", "ui.approval.source.session": "توسط قانون تأیید خودکار جلسه", "ui.approval.source.default": "به‌طور پیش‌فرض", - "ui.approval.outsideWorkspace": "path is outside your workspace", + "ui.approval.outsideWorkspace": "مسیر خارج از فضای کاری شما است", "session.tab.review": "بررسی", "session.review.filesChanged": "{{count}} فایل تغییر یافته", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 3349663f683..f911da4bf0d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -307,7 +307,7 @@ export const dict = { "ui.approval.source.yolo": "par le mode d'approbation automatique (YOLO)", "ui.approval.source.session": "par une règle d'approbation automatique de session", "ui.approval.source.default": "par défaut", - "ui.approval.outsideWorkspace": "path is outside your workspace", + "ui.approval.outsideWorkspace": "le chemin se trouve en dehors de votre espace de travail", "session.tab.review": "Revue", "session.review.filesChanged": "{{count}} fichiers modifiés", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index 5600b4b533f..fa24e22e0ad 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -219,7 +219,7 @@ export const dict = { "ui.approval.source.yolo": "dalla modalità di approvazione automatica (YOLO)", "ui.approval.source.session": "da una regola di approvazione automatica della sessione", "ui.approval.source.default": "per impostazione predefinita", - "ui.approval.outsideWorkspace": "path is outside your workspace", + "ui.approval.outsideWorkspace": "il percorso si trova fuori dall'area di lavoro", "session.tab.review": "Revisione", "session.review.filesChanged": "{{count}} file modificati", "session.review.loadingChanges": "Caricamento modifiche...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 7f38cab2ecb..14337d35c98 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -304,7 +304,7 @@ export const dict = { "ui.approval.source.yolo": "自動承認(YOLO)モードによって", "ui.approval.source.session": "セッションの自動承認ルールによって", "ui.approval.source.default": "デフォルトで", - "ui.approval.outsideWorkspace": "path is outside your workspace", + "ui.approval.outsideWorkspace": "パスがワークスペースの外にあります", "session.tab.review": "レビュー", "session.review.filesChanged": "{{count}} ファイル変更", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 6c88e3d17ac..e9cc284d1d0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -305,7 +305,7 @@ export const dict = { "ui.approval.source.yolo": "자동 승인(YOLO) 모드에 의해", "ui.approval.source.session": "세션 자동 승인 규칙에 의해", "ui.approval.source.default": "기본값으로", - "ui.approval.outsideWorkspace": "path is outside your workspace", + "ui.approval.outsideWorkspace": "경로가 작업 영역 외부에 있습니다", "session.tab.review": "검토", "session.review.filesChanged": "{{count}}개 파일 변경됨", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 52ebf8e8787..682a87ec9be 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -308,7 +308,7 @@ export const dict = { "ui.approval.source.yolo": "door de automatische goedkeuringsmodus (YOLO)", "ui.approval.source.session": "door een sessie-automatische-goedkeuringsregel", "ui.approval.source.default": "standaard", - "ui.approval.outsideWorkspace": "path is outside your workspace", + "ui.approval.outsideWorkspace": "het pad ligt buiten je werkruimte", "session.tab.review": "Beoordelen", "session.review.filesChanged": "{{count}} bestanden gewijzigd", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index a0e4809dcd7..1e2cc66adc8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -311,7 +311,7 @@ export const dict = { "ui.approval.source.yolo": "av automatisk godkjenning (YOLO)", "ui.approval.source.session": "av en økt-autogodkjenningsregel", "ui.approval.source.default": "som standard", - "ui.approval.outsideWorkspace": "path is outside your workspace", + "ui.approval.outsideWorkspace": "stien er utenfor arbeidsområdet ditt", "session.tab.review": "Gjennomgang", "session.review.filesChanged": "{{count}} filer endret", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 8f655adac46..0e0beb91eaa 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -305,7 +305,7 @@ export const dict = { "ui.approval.source.yolo": "przez tryb automatycznego zatwierdzania (YOLO)", "ui.approval.source.session": "przez regułę automatycznego zatwierdzania sesji", "ui.approval.source.default": "domyślnie", - "ui.approval.outsideWorkspace": "path is outside your workspace", + "ui.approval.outsideWorkspace": "ścieżka znajduje się poza obszarem roboczym", "session.tab.review": "Przegląd", "session.review.filesChanged": "Zmieniono {{count}} plików", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 9731d5a1523..f95a4330dda 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -303,7 +303,7 @@ export const dict = { "ui.approval.source.yolo": "режимом автоодобрения (YOLO)", "ui.approval.source.session": "правилом автоодобрения сессии", "ui.approval.source.default": "по умолчанию", - "ui.approval.outsideWorkspace": "path is outside your workspace", + "ui.approval.outsideWorkspace": "путь находится за пределами вашей рабочей области", "session.tab.review": "Обзор", "session.review.filesChanged": "{{count}} файлов изменено", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 2d6f0cac2ba..b53e8e257e3 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -302,7 +302,7 @@ export const dict = { "ui.approval.source.yolo": "โดยโหมดอนุมัติอัตโนมัติ (YOLO)", "ui.approval.source.session": "โดยกฎอนุมัติอัตโนมัติของเซสชัน", "ui.approval.source.default": "ตามค่าเริ่มต้น", - "ui.approval.outsideWorkspace": "path is outside your workspace", + "ui.approval.outsideWorkspace": "พาธอยู่นอกพื้นที่ทำงานของคุณ", "session.tab.review": "ตรวจสอบ", "session.review.filesChanged": "{{count}} ไฟล์ที่เปลี่ยนแปลง", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 0671e4a0fa8..fb724d75589 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -303,7 +303,7 @@ export const dict = { "ui.approval.source.yolo": "otomatik onay (YOLO) modu tarafından", "ui.approval.source.session": "bir oturum otomatik onay kuralı tarafından", "ui.approval.source.default": "varsayılan olarak", - "ui.approval.outsideWorkspace": "path is outside your workspace", + "ui.approval.outsideWorkspace": "yol çalışma alanınızın dışında", "session.tab.review": "İnceleme", "session.review.filesChanged": "{{count}} Dosya Değişti", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index a763b3b8d32..57cd805aee4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -307,7 +307,7 @@ export const dict = { "ui.approval.source.yolo": "режимом автосхвалення (YOLO)", "ui.approval.source.session": "правилом автосхвалення сесії", "ui.approval.source.default": "за замовчуванням", - "ui.approval.outsideWorkspace": "path is outside your workspace", + "ui.approval.outsideWorkspace": "шлях знаходиться за межами вашого робочого простору", "session.tab.review": "Огляд", "session.review.filesChanged": "{{count}} файлів змінено", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index f2037d94047..a6c0236772b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -292,7 +292,7 @@ export const dict = { "ui.approval.source.yolo": "由自动批准(YOLO)模式", "ui.approval.source.session": "由会话自动批准规则", "ui.approval.source.default": "默认", - "ui.approval.outsideWorkspace": "path is outside your workspace", + "ui.approval.outsideWorkspace": "路径在你的工作区之外", "session.tab.review": "审查", "session.review.filesChanged": "{{count}} 个文件变更", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index d78c0885301..b1ac1b008a9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -290,7 +290,7 @@ export const dict = { "ui.approval.source.yolo": "由自動核准(YOLO)模式", "ui.approval.source.session": "由工作階段自動核准規則", "ui.approval.source.default": "預設", - "ui.approval.outsideWorkspace": "path is outside your workspace", + "ui.approval.outsideWorkspace": "路徑在你的工作區之外", "session.tab.review": "審查", "session.review.filesChanged": "{{count}} 個檔案變更", From 9c36685c0f7ff218f8733f0ada1807ba5149b7d5 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Fri, 7 Aug 2026 15:10:28 +0200 Subject: [PATCH 10/34] fix(vscode): show filename inline in outside-workspace approval note --- .../src/components/tool-approval.test.ts | 18 +++++++-- .../kilo-ui/src/components/tool-approval.tsx | 9 ++++- .../kilo-vscode/webview-ui/src/i18n/ar.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/br.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/bs.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/da.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/de.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/en.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/es.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/fa.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/fr.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/it.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/ja.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/ko.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/nl.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/no.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/pl.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/ru.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/th.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/tr.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/uk.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/zh.ts | 2 +- .../kilo-vscode/webview-ui/src/i18n/zht.ts | 2 +- .../src/kilocode/permission/provenance.ts | 21 ++++++++-- packages/opencode/src/session/tools.ts | 9 ++++- .../kilocode/permission/provenance.test.ts | 38 +++++++++++++++++++ 26 files changed, 108 insertions(+), 29 deletions(-) diff --git a/packages/kilo-ui/src/components/tool-approval.test.ts b/packages/kilo-ui/src/components/tool-approval.test.ts index 414e18ee34c..f61b7c5c8cd 100644 --- a/packages/kilo-ui/src/components/tool-approval.test.ts +++ b/packages/kilo-ui/src/components/tool-approval.test.ts @@ -55,10 +55,15 @@ describe("resolveToolApproval", () => { expect(out?.rule).toBeUndefined() }) - test("adds the outsideWorkspace text when the approval was marked outside the workspace", () => { - const approval = { source: "agent" as const, agent: "code", outsideWorkspace: true } + test("adds the outsideWorkspace text with just the filename when a path is known", () => { + const approval = { + source: "agent" as const, + agent: "code", + outsideWorkspace: true, + outsideWorkspacePath: "/etc/secrets/hello.txt", + } const out = resolveToolApproval({ approval }, t) - expect(out?.outsideWorkspace).toBe("ui.approval.outsideWorkspace") + expect(out?.outsideWorkspace).toBe("ui.approval.outsideWorkspace(file=hello.txt)") }) test("omits the outsideWorkspace text for an ordinary in-workspace approval", () => { @@ -66,4 +71,11 @@ describe("resolveToolApproval", () => { const out = resolveToolApproval({ approval }, t) expect(out?.outsideWorkspace).toBeUndefined() }) + + test("omits the outsideWorkspace text when outsideWorkspace is set but no path is known", () => { + // e.g. a bash command scanning multiple external directories has no single filepath to show. + const approval = { source: "agent" as const, agent: "code", outsideWorkspace: true } + const out = resolveToolApproval({ approval }, t) + expect(out?.outsideWorkspace).toBeUndefined() + }) }) diff --git a/packages/kilo-ui/src/components/tool-approval.tsx b/packages/kilo-ui/src/components/tool-approval.tsx index a245e145b94..aa9d6c322ae 100644 --- a/packages/kilo-ui/src/components/tool-approval.tsx +++ b/packages/kilo-ui/src/components/tool-approval.tsx @@ -1,4 +1,5 @@ import { createContext, useContext, Show, type Accessor, type ParentProps } from "solid-js" +import { getFilename } from "@opencode-ai/core/util/path" import { Icon } from "./icon" /** @@ -14,6 +15,8 @@ export type ToolApproval = { rule?: { permission: string; pattern: string; action: string } /** True when the tool call's target path was outside the workspace/worktree. */ outsideWorkspace?: boolean + /** The target file path, when known, for display as a filename next to the note above. */ + outsideWorkspacePath?: string } /** Pre-resolved, localized text plus the raw approval, supplied by the caller. */ @@ -85,12 +88,16 @@ export function resolveToolApproval( rule && !(rule.permission === "*" && rule.pattern === "*") ? t("ui.approval.rule", { permission: rule.permission, pattern: rule.pattern }) : undefined + // Only worth calling out when we know which file it was; a bare "outside your workspace" note + // without a filename (e.g. a bash command touching several directories) isn't actionable. + const filename = approval.outsideWorkspacePath ? getFilename(approval.outsideWorkspacePath) : undefined return { approval, decision: approval.source === "manual" ? t("ui.approval.manual") : t("ui.approval.auto"), source: sourceText(), rule: ruleText, - outsideWorkspace: approval.outsideWorkspace ? t("ui.approval.outsideWorkspace") : undefined, + outsideWorkspace: + approval.outsideWorkspace && filename ? t("ui.approval.outsideWorkspace", { file: filename }) : undefined, } } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 9d59467f9ed..9a3310b6555 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -297,7 +297,7 @@ export const dict = { "ui.approval.source.yolo": "بواسطة وضع الموافقة التلقائية (YOLO)", "ui.approval.source.session": "بواسطة قاعدة موافقة تلقائية للجلسة", "ui.approval.source.default": "افتراضيًا", - "ui.approval.outsideWorkspace": "المسار خارج مساحة العمل الخاصة بك", + "ui.approval.outsideWorkspace": "(خارج مساحة العمل: {{file}})", "session.tab.review": "مراجعة", "session.review.filesChanged": "تم تغيير {{count}} ملفات", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 4ea354d7d1b..0df91b651c9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -307,7 +307,7 @@ export const dict = { "ui.approval.source.yolo": "pelo modo de aprovação automática (YOLO)", "ui.approval.source.session": "por uma regra de aprovação automática da sessão", "ui.approval.source.default": "por padrão", - "ui.approval.outsideWorkspace": "o caminho está fora do seu espaço de trabalho", + "ui.approval.outsideWorkspace": "(fora do seu espaço de trabalho: {{file}})", "session.tab.review": "Revisão", "session.review.filesChanged": "{{count}} Arquivos Alterados", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 953b1378320..db0f4590145 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -305,7 +305,7 @@ export const dict = { "ui.approval.source.yolo": "režimom automatskog odobravanja (YOLO)", "ui.approval.source.session": "pravilom automatskog odobravanja sesije", "ui.approval.source.default": "podrazumevano", - "ui.approval.outsideWorkspace": "putanja je izvan vašeg radnog prostora", + "ui.approval.outsideWorkspace": "(izvan vašeg radnog prostora: {{file}})", "session.tab.review": "Pregled", "session.review.filesChanged": "Izmijenjeno {{count}} datoteka", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index d0433fdff8e..6f36adc3d33 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -304,7 +304,7 @@ export const dict = { "ui.approval.source.yolo": "af automatisk godkendelse (YOLO)", "ui.approval.source.session": "af en session-autogodkendelsesregel", "ui.approval.source.default": "som standard", - "ui.approval.outsideWorkspace": "stien er uden for dit arbejdsområde", + "ui.approval.outsideWorkspace": "(uden for dit arbejdsområde: {{file}})", "session.tab.review": "Gennemgang", "session.review.filesChanged": "{{count}} Filer ændret", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 67ca6ef3bcb..4066d9396d9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -313,7 +313,7 @@ export const dict = { "ui.approval.source.yolo": "durch den Auto-Genehmigungsmodus (YOLO)", "ui.approval.source.session": "durch eine Sitzungs-Auto-Genehmigungsregel", "ui.approval.source.default": "standardmäßig", - "ui.approval.outsideWorkspace": "der Pfad liegt außerhalb deines Arbeitsbereichs", + "ui.approval.outsideWorkspace": "(außerhalb deines Arbeitsbereichs: {{file}})", "session.tab.review": "Überprüfung", "session.review.filesChanged": "{{count}} Dateien geändert", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index af4d5972c9c..80f804d969d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -302,7 +302,7 @@ export const dict = { "ui.approval.source.yolo": "by auto-approve (YOLO) mode", "ui.approval.source.session": "by a session auto-approve rule", "ui.approval.source.default": "by default", - "ui.approval.outsideWorkspace": "path is outside your workspace", + "ui.approval.outsideWorkspace": "(outside your workspace: {{file}})", "session.tab.review": "Review", "session.review.filesChanged": "{{count}} Files Changed", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index a8de2c15dab..558eb4a26ae 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -308,7 +308,7 @@ export const dict = { "ui.approval.source.yolo": "por el modo de aprobación automática (YOLO)", "ui.approval.source.session": "por una regla de aprobación automática de sesión", "ui.approval.source.default": "de forma predeterminada", - "ui.approval.outsideWorkspace": "la ruta está fuera de tu espacio de trabajo", + "ui.approval.outsideWorkspace": "(fuera de tu espacio de trabajo: {{file}})", "session.tab.review": "Revisión", "session.review.filesChanged": "{{count}} Archivos Cambiados", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts index 45974a628f5..cda714862bc 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -302,7 +302,7 @@ export const dict = { "ui.approval.source.yolo": "توسط حالت تأیید خودکار (YOLO)", "ui.approval.source.session": "توسط قانون تأیید خودکار جلسه", "ui.approval.source.default": "به‌طور پیش‌فرض", - "ui.approval.outsideWorkspace": "مسیر خارج از فضای کاری شما است", + "ui.approval.outsideWorkspace": "(خارج از فضای کاری شما: {{file}})", "session.tab.review": "بررسی", "session.review.filesChanged": "{{count}} فایل تغییر یافته", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index f911da4bf0d..0292d8767a9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -307,7 +307,7 @@ export const dict = { "ui.approval.source.yolo": "par le mode d'approbation automatique (YOLO)", "ui.approval.source.session": "par une règle d'approbation automatique de session", "ui.approval.source.default": "par défaut", - "ui.approval.outsideWorkspace": "le chemin se trouve en dehors de votre espace de travail", + "ui.approval.outsideWorkspace": "(hors de votre espace de travail : {{file}})", "session.tab.review": "Revue", "session.review.filesChanged": "{{count}} fichiers modifiés", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index fa24e22e0ad..10ef2fd2dcb 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -219,7 +219,7 @@ export const dict = { "ui.approval.source.yolo": "dalla modalità di approvazione automatica (YOLO)", "ui.approval.source.session": "da una regola di approvazione automatica della sessione", "ui.approval.source.default": "per impostazione predefinita", - "ui.approval.outsideWorkspace": "il percorso si trova fuori dall'area di lavoro", + "ui.approval.outsideWorkspace": "(fuori dall'area di lavoro: {{file}})", "session.tab.review": "Revisione", "session.review.filesChanged": "{{count}} file modificati", "session.review.loadingChanges": "Caricamento modifiche...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 14337d35c98..67c60731ef5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -304,7 +304,7 @@ export const dict = { "ui.approval.source.yolo": "自動承認(YOLO)モードによって", "ui.approval.source.session": "セッションの自動承認ルールによって", "ui.approval.source.default": "デフォルトで", - "ui.approval.outsideWorkspace": "パスがワークスペースの外にあります", + "ui.approval.outsideWorkspace": "(ワークスペース外:{{file}})", "session.tab.review": "レビュー", "session.review.filesChanged": "{{count}} ファイル変更", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index e9cc284d1d0..e7d0664e7b7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -305,7 +305,7 @@ export const dict = { "ui.approval.source.yolo": "자동 승인(YOLO) 모드에 의해", "ui.approval.source.session": "세션 자동 승인 규칙에 의해", "ui.approval.source.default": "기본값으로", - "ui.approval.outsideWorkspace": "경로가 작업 영역 외부에 있습니다", + "ui.approval.outsideWorkspace": "(작업 영역 외부: {{file}})", "session.tab.review": "검토", "session.review.filesChanged": "{{count}}개 파일 변경됨", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 682a87ec9be..40169d8672d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -308,7 +308,7 @@ export const dict = { "ui.approval.source.yolo": "door de automatische goedkeuringsmodus (YOLO)", "ui.approval.source.session": "door een sessie-automatische-goedkeuringsregel", "ui.approval.source.default": "standaard", - "ui.approval.outsideWorkspace": "het pad ligt buiten je werkruimte", + "ui.approval.outsideWorkspace": "(buiten je werkruimte: {{file}})", "session.tab.review": "Beoordelen", "session.review.filesChanged": "{{count}} bestanden gewijzigd", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 1e2cc66adc8..bea79dee720 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -311,7 +311,7 @@ export const dict = { "ui.approval.source.yolo": "av automatisk godkjenning (YOLO)", "ui.approval.source.session": "av en økt-autogodkjenningsregel", "ui.approval.source.default": "som standard", - "ui.approval.outsideWorkspace": "stien er utenfor arbeidsområdet ditt", + "ui.approval.outsideWorkspace": "(utenfor arbeidsområdet ditt: {{file}})", "session.tab.review": "Gjennomgang", "session.review.filesChanged": "{{count}} filer endret", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 0e0beb91eaa..701c94122f9 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -305,7 +305,7 @@ export const dict = { "ui.approval.source.yolo": "przez tryb automatycznego zatwierdzania (YOLO)", "ui.approval.source.session": "przez regułę automatycznego zatwierdzania sesji", "ui.approval.source.default": "domyślnie", - "ui.approval.outsideWorkspace": "ścieżka znajduje się poza obszarem roboczym", + "ui.approval.outsideWorkspace": "(poza obszarem roboczym: {{file}})", "session.tab.review": "Przegląd", "session.review.filesChanged": "Zmieniono {{count}} plików", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index f95a4330dda..9f64e84a176 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -303,7 +303,7 @@ export const dict = { "ui.approval.source.yolo": "режимом автоодобрения (YOLO)", "ui.approval.source.session": "правилом автоодобрения сессии", "ui.approval.source.default": "по умолчанию", - "ui.approval.outsideWorkspace": "путь находится за пределами вашей рабочей области", + "ui.approval.outsideWorkspace": "(за пределами вашей рабочей области: {{file}})", "session.tab.review": "Обзор", "session.review.filesChanged": "{{count}} файлов изменено", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index b53e8e257e3..5b0e38ddf14 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -302,7 +302,7 @@ export const dict = { "ui.approval.source.yolo": "โดยโหมดอนุมัติอัตโนมัติ (YOLO)", "ui.approval.source.session": "โดยกฎอนุมัติอัตโนมัติของเซสชัน", "ui.approval.source.default": "ตามค่าเริ่มต้น", - "ui.approval.outsideWorkspace": "พาธอยู่นอกพื้นที่ทำงานของคุณ", + "ui.approval.outsideWorkspace": "(นอกพื้นที่ทำงานของคุณ: {{file}})", "session.tab.review": "ตรวจสอบ", "session.review.filesChanged": "{{count}} ไฟล์ที่เปลี่ยนแปลง", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index fb724d75589..83a9d8f16ce 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -303,7 +303,7 @@ export const dict = { "ui.approval.source.yolo": "otomatik onay (YOLO) modu tarafından", "ui.approval.source.session": "bir oturum otomatik onay kuralı tarafından", "ui.approval.source.default": "varsayılan olarak", - "ui.approval.outsideWorkspace": "yol çalışma alanınızın dışında", + "ui.approval.outsideWorkspace": "(çalışma alanınızın dışında: {{file}})", "session.tab.review": "İnceleme", "session.review.filesChanged": "{{count}} Dosya Değişti", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 57cd805aee4..e773ee2d54a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -307,7 +307,7 @@ export const dict = { "ui.approval.source.yolo": "режимом автосхвалення (YOLO)", "ui.approval.source.session": "правилом автосхвалення сесії", "ui.approval.source.default": "за замовчуванням", - "ui.approval.outsideWorkspace": "шлях знаходиться за межами вашого робочого простору", + "ui.approval.outsideWorkspace": "(за межами вашого робочого простору: {{file}})", "session.tab.review": "Огляд", "session.review.filesChanged": "{{count}} файлів змінено", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index a6c0236772b..249a332bd1b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -292,7 +292,7 @@ export const dict = { "ui.approval.source.yolo": "由自动批准(YOLO)模式", "ui.approval.source.session": "由会话自动批准规则", "ui.approval.source.default": "默认", - "ui.approval.outsideWorkspace": "路径在你的工作区之外", + "ui.approval.outsideWorkspace": "(工作区之外:{{file}})", "session.tab.review": "审查", "session.review.filesChanged": "{{count}} 个文件变更", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index b1ac1b008a9..93746129e74 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -290,7 +290,7 @@ export const dict = { "ui.approval.source.yolo": "由自動核准(YOLO)模式", "ui.approval.source.session": "由工作階段自動核准規則", "ui.approval.source.default": "預設", - "ui.approval.outsideWorkspace": "路徑在你的工作區之外", + "ui.approval.outsideWorkspace": "(工作區之外:{{file}})", "session.tab.review": "審查", "session.review.filesChanged": "{{count}} 個檔案變更", diff --git a/packages/opencode/src/kilocode/permission/provenance.ts b/packages/opencode/src/kilocode/permission/provenance.ts index 896db47f1f9..38b244448ec 100644 --- a/packages/opencode/src/kilocode/permission/provenance.ts +++ b/packages/opencode/src/kilocode/permission/provenance.ts @@ -29,11 +29,19 @@ export namespace PermissionProvenance { rule?: { permission: string; pattern: string; action: Permission.Action } /** True when the ask's target path was outside the workspace/worktree (an `external_directory` ask). */ outsideWorkspace?: boolean + /** The target file path, when the `external_directory` ask carried one, for display as a filename. */ + outsideWorkspacePath?: string + } + + /** The `filepath` an `external_directory` ask's metadata carries, if any (see `Tool.assertExternalDirectory`). */ + export function filepathOf(metadata: Record | undefined): string | undefined { + return typeof metadata?.filepath === "string" ? metadata.filepath : undefined } /** Tag an approval as outside-workspace when it answers an `external_directory` ask. */ - export function tagOutsideWorkspace(approval: Approval, permission: string): Approval { - return permission === "external_directory" ? { ...approval, outsideWorkspace: true } : approval + export function tagOutsideWorkspace(approval: Approval, permission: string, path?: string): Approval { + if (permission !== "external_directory") return approval + return { ...approval, outsideWorkspace: true, ...(path ? { outsideWorkspacePath: path } : {}) } } export type Scope = "global" | "local" @@ -93,7 +101,14 @@ export namespace PermissionProvenance { if (!("approval" in next)) return prior ? { ...next, approval: prior } : next const current = next.approval as Approval | undefined if (!prior?.outsideWorkspace || !current || current.outsideWorkspace) return next - return { ...next, approval: { ...current, outsideWorkspace: true } } + return { + ...next, + approval: { + ...current, + outsideWorkspace: true, + ...(prior.outsideWorkspacePath ? { outsideWorkspacePath: prior.outsideWorkspacePath } : {}), + }, + } } /** diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index fa2a827ebd8..9b4907512a6 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -99,7 +99,13 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { // record why the call was allowed onto the tool part, then discard the outcome for the tool-facing ask Effect.tap((approval) => input.processor.metadata(options.toolCallId, { - metadata: { approval: PermissionProvenance.tagOutsideWorkspace(approval, req.permission) }, + metadata: { + approval: PermissionProvenance.tagOutsideWorkspace( + approval, + req.permission, + PermissionProvenance.filepathOf(req.metadata), + ), + }, }), ), // record why the call was denied too, so JSON exports and clients can explain the denial @@ -115,6 +121,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { origins: permissionOrigins, }), req.permission, + PermissionProvenance.filepathOf(req.metadata), ), }, }), diff --git a/packages/opencode/test/kilocode/permission/provenance.test.ts b/packages/opencode/test/kilocode/permission/provenance.test.ts index 816d577d995..b2ffae3ecdd 100644 --- a/packages/opencode/test/kilocode/permission/provenance.test.ts +++ b/packages/opencode/test/kilocode/permission/provenance.test.ts @@ -132,6 +132,14 @@ describe("PermissionProvenance.carryApproval", () => { }) }) + test("also carries the outsideWorkspacePath forward alongside the marker", () => { + const outside = { source: "manual" as const, outsideWorkspace: true, outsideWorkspacePath: "/tmp/secret.txt" } + const next = { approval: { source: "agent" as const, agent: "build" } } + expect(PermissionProvenance.carryApproval({ approval: outside }, next)).toEqual({ + approval: { source: "agent", agent: "build", outsideWorkspace: true, outsideWorkspacePath: "/tmp/secret.txt" }, + }) + }) + test("does not add outsideWorkspace when the prior approval was not outside the workspace", () => { const next = { approval: { source: "agent" as const, agent: "build" } } expect(PermissionProvenance.carryApproval({ approval }, next)).toBe(next) @@ -157,6 +165,36 @@ describe("PermissionProvenance.tagOutsideWorkspace", () => { const approval = { source: "manual" as const } expect(PermissionProvenance.tagOutsideWorkspace(approval, "read")).toBe(approval) }) + + test("carries the target path when one is given", () => { + const approval = { source: "manual" as const } + expect(PermissionProvenance.tagOutsideWorkspace(approval, "external_directory", "/tmp/secret.txt")).toEqual({ + source: "manual", + outsideWorkspace: true, + outsideWorkspacePath: "/tmp/secret.txt", + }) + }) + + test("omits outsideWorkspacePath when no path is given", () => { + const approval = { source: "manual" as const } + expect(PermissionProvenance.tagOutsideWorkspace(approval, "external_directory")).toEqual({ + source: "manual", + outsideWorkspace: true, + }) + }) +}) + +describe("PermissionProvenance.filepathOf", () => { + test("reads the filepath an external_directory ask's metadata carries", () => { + expect(PermissionProvenance.filepathOf({ filepath: "/tmp/secret.txt", parentDir: "/tmp" })).toBe( + "/tmp/secret.txt", + ) + }) + + test("returns undefined when there is no filepath, e.g. a bash directory scan", () => { + expect(PermissionProvenance.filepathOf({ command: "cat /tmp/secret.txt", access: "read" })).toBeUndefined() + expect(PermissionProvenance.filepathOf(undefined)).toBeUndefined() + }) }) describe("askPermission returns provenance", () => { From 910f0f24d2b38ff04b43dee694f6968608f54eb1 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Fri, 7 Aug 2026 16:45:50 +0200 Subject: [PATCH 11/34] fix(cli): disable suggest tool in non-interactive runs --- ...disable-suggest-in-non-interactive-runs.md | 5 +++ .../opencode/src/cli/cmd/github.handler.ts | 7 ++++ packages/opencode/src/cli/cmd/run.ts | 17 +++++++- packages/opencode/src/kilocode/tool/task.ts | 1 + packages/opencode/test/cli/auto-mode.test.ts | 15 ++++++- .../opencode/test/cli/run/run-process.test.ts | 41 +++++++++++++++++++ .../test/kilocode/task-nesting.test.ts | 5 +++ 7 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 .changeset/disable-suggest-in-non-interactive-runs.md diff --git a/.changeset/disable-suggest-in-non-interactive-runs.md b/.changeset/disable-suggest-in-non-interactive-runs.md new file mode 100644 index 00000000000..d75f2b540ee --- /dev/null +++ b/.changeset/disable-suggest-in-non-interactive-runs.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Disable the suggest tool and auto-dismiss pending suggestions in non-interactive CLI runs to prevent hanging on benchmarks and automated pipelines. diff --git a/packages/opencode/src/cli/cmd/github.handler.ts b/packages/opencode/src/cli/cmd/github.handler.ts index 9727a76c2a0..1a94e86ebb1 100644 --- a/packages/opencode/src/cli/cmd/github.handler.ts +++ b/packages/opencode/src/cli/cmd/github.handler.ts @@ -516,6 +516,13 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?: action: "deny", pattern: "*", }, + // kilocode_change start + { + permission: "suggest", + action: "deny", + pattern: "*", + }, + // kilocode_change end ], }), ) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 1ae73d53c62..36bcabfba86 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -472,7 +472,12 @@ export const RunCommand = effectCmd({ action: "deny", pattern: "*", }, - // kilocode_change start - non-interactive runs cannot take over a terminal + // kilocode_change start - non-interactive runs cannot answer suggestions or take over a terminal + { + permission: "suggest", + action: "deny", + pattern: "*", + }, { permission: "interactive_terminal", action: "deny", @@ -904,6 +909,16 @@ export const RunCommand = effectCmd({ break } + // kilocode_change start - non-interactive runs dismiss suggestions so they don't block + if (event.type === "suggestion.shown") { + const suggestion = event.properties + if (suggestion.sessionID === sessionID || KiloRunAuto.allowed(tracked, suggestion.sessionID)) { + await client.suggestion.dismiss({ requestID: suggestion.id }).catch(() => {}) + } + continue + } + // kilocode_change end + if (event.type === "permission.asked") { const permission = event.properties // kilocode_change start - skill shell batches need an interactive human decision. The server ignores diff --git a/packages/opencode/src/kilocode/tool/task.ts b/packages/opencode/src/kilocode/tool/task.ts index f5d094df7bc..3337423c8d9 100644 --- a/packages/opencode/src/kilocode/tool/task.ts +++ b/packages/opencode/src/kilocode/tool/task.ts @@ -80,6 +80,7 @@ export namespace KiloTask { return [ { permission: "task", pattern: "*", action: "deny" }, { permission: "question", pattern: "*", action: "deny" }, + { permission: "suggest", pattern: "*", action: "deny" }, { permission: "interactive_terminal", pattern: "*", action: "deny" }, ...rules, ] diff --git a/packages/opencode/test/cli/auto-mode.test.ts b/packages/opencode/test/cli/auto-mode.test.ts index eef67fcc56c..598d4017859 100644 --- a/packages/opencode/test/cli/auto-mode.test.ts +++ b/packages/opencode/test/cli/auto-mode.test.ts @@ -18,6 +18,11 @@ describe("Auto mode flag", () => { action: "deny" as const, pattern: "*", }, + { + permission: "suggest", + action: "deny" as const, + pattern: "*", + }, { permission: "interactive_terminal", action: "deny" as const, @@ -25,7 +30,7 @@ describe("Auto mode flag", () => { }, ] - expect(autoPermissions).toHaveLength(3) + expect(autoPermissions).toHaveLength(4) // First rule: allow all expect(autoPermissions[0].permission).toBe("*") @@ -36,9 +41,12 @@ describe("Auto mode flag", () => { expect(autoPermissions[1].permission).toBe("question") expect(autoPermissions[1].action).toBe("deny") expect(autoPermissions[1].pattern).toBe("*") - expect(autoPermissions[2].permission).toBe("interactive_terminal") + expect(autoPermissions[2].permission).toBe("suggest") expect(autoPermissions[2].action).toBe("deny") expect(autoPermissions[2].pattern).toBe("*") + expect(autoPermissions[3].permission).toBe("interactive_terminal") + expect(autoPermissions[3].action).toBe("deny") + expect(autoPermissions[3].pattern).toBe("*") }) test("non-auto mode should not set allow-all permissions", () => { @@ -55,6 +63,7 @@ describe("Auto mode flag", () => { const autoPermissions = [ { permission: "*", action: "allow" as const, pattern: "*" }, { permission: "question", action: "deny" as const, pattern: "*" }, + { permission: "suggest", action: "deny" as const, pattern: "*" }, { permission: "interactive_terminal", action: "deny" as const, pattern: "*" }, ] @@ -71,6 +80,8 @@ describe("Auto mode flag", () => { // Test that "question" permission resolves to "deny" const questionRule = findLastMatch("question") expect(questionRule?.action).toBe("deny") + const suggestRule = findLastMatch("suggest") + expect(suggestRule?.action).toBe("deny") const terminalRule = findLastMatch("interactive_terminal") expect(terminalRule?.action).toBe("deny") diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index 2dce9e8cb21..70fcbe5bea8 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -412,4 +412,45 @@ describe("opencode run (non-interactive subprocess)", () => { }), 60_000, // kilocode_change ) + + // kilocode_change start - non-interactive runs exclude human-driven tools like suggest + cliIt.concurrent( + "kilo run --auto excludes suggest tool from LLM request", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.text("done") + const result = yield* opencode.run("do work", { extraArgs: ["--auto"] }) + opencode.expectExit(result, 0) + expect(result.stdout).toBe("done\n") + const inputs = yield* llm.inputs + const tools = (inputs[0]?.body as any)?.tools as Array<{ function?: { name?: string } }> | undefined + const toolNames = tools?.map((t) => t.function?.name).filter(Boolean) ?? [] + expect(toolNames).not.toContain("suggest") + expect(toolNames).not.toContain("question") + expect(toolNames).not.toContain("interactive_terminal") + }), + 60_000, + ) + + cliIt.concurrent( + "kilo run auto-dismisses suggestion and exits cleanly if suggest tool is invoked", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.push( + reply().tool("suggest", { + suggest: "Run checks?", + actions: [{ label: "Run checks", prompt: "Run checks" }], + }), + ) + yield* llm.text("completed after dismissal") + const result = yield* opencode.run("do work", { + permission: { suggest: "allow" }, + extraArgs: ["--auto"], + }) + opencode.expectExit(result, 0) + expect(result.stdout).toContain("completed after dismissal") + }), + 60_000, + ) + // kilocode_change end }) diff --git a/packages/opencode/test/kilocode/task-nesting.test.ts b/packages/opencode/test/kilocode/task-nesting.test.ts index 4d566155130..a57b682905c 100644 --- a/packages/opencode/test/kilocode/task-nesting.test.ts +++ b/packages/opencode/test/kilocode/task-nesting.test.ts @@ -279,6 +279,11 @@ describe("Kilo task nesting", () => { pattern: "*", action: "deny", }, + { + permission: "suggest", + pattern: "*", + action: "deny", + }, { permission: "interactive_terminal", pattern: "*", From 82be537f18985223d23612477fa3f4ce30ed57aa Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Fri, 7 Aug 2026 17:07:31 +0200 Subject: [PATCH 12/34] test(cli): verify suggestion auto-dismissal in attached run session --- .../opencode/test/cli/run/run-process.test.ts | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index 70fcbe5bea8..5841afa6043 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -5,6 +5,7 @@ // `KILO_CONFIG_CONTENT` providing the test provider config inline. import { describe, expect } from "bun:test" import { Effect } from "effect" +import { createKiloClient } from "@kilocode/sdk/v2" // kilocode_change import { reply } from "../../lib/llm-server" import { cliIt } from "../../lib/cli-process" @@ -432,10 +433,20 @@ describe("opencode run (non-interactive subprocess)", () => { 60_000, ) - cliIt.concurrent( - "kilo run auto-dismisses suggestion and exits cleanly if suggest tool is invoked", + cliIt.live( + "kilo run auto-dismisses suggestion and exits cleanly if suggest tool is invoked in attached session", ({ llm, opencode }) => Effect.gen(function* () { + const server = yield* opencode.serve() + const client = createKiloClient({ baseUrl: server.url }) + const session = yield* Effect.promise(() => + client.session.create({ + permission: [{ permission: "suggest", action: "allow", pattern: "*" }], + }), + ) + const sessionID = session.data?.id + expect(sessionID).toBeDefined() + yield* llm.push( reply().tool("suggest", { suggest: "Run checks?", @@ -443,12 +454,17 @@ describe("opencode run (non-interactive subprocess)", () => { }), ) yield* llm.text("completed after dismissal") + const result = yield* opencode.run("do work", { - permission: { suggest: "allow" }, - extraArgs: ["--auto"], + extraArgs: ["--attach", server.url, "--session", sessionID!, "--auto"], }) opencode.expectExit(result, 0) - expect(result.stdout).toContain("completed after dismissal") + + const messages = yield* Effect.promise(() => client.session.messages({ sessionID: sessionID! })) + const assistant = messages.data?.findLast((m) => m.info.role === "assistant") + const toolPart = assistant?.parts.find((p) => p.type === "tool" && p.tool === "suggest") + expect(toolPart).toBeDefined() + expect((toolPart as any)?.state?.metadata?.dismissed).toBe(true) }), 60_000, ) From 356f5b0a6c888764d41cf533c9e8963a4a7bf94f Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Fri, 7 Aug 2026 19:19:07 +0200 Subject: [PATCH 13/34] fix(ui): drop italic styling and highlight decision text in tool approval line --- packages/kilo-ui/src/components/basic-tool.css | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/kilo-ui/src/components/basic-tool.css b/packages/kilo-ui/src/components/basic-tool.css index 9d7ff5acc76..7f54737247e 100644 --- a/packages/kilo-ui/src/components/basic-tool.css +++ b/packages/kilo-ui/src/components/basic-tool.css @@ -508,8 +508,8 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty } /* "why was this allowed" line inside a tool's expanded body. Styled like - [data-component="tool-hint"] (muted, italic) so it reads as ambient - context rather than a call to action, and recedes the way reasoning text does. */ + [data-component="tool-hint"] (muted) so it reads as ambient context rather + than a call to action, and recedes the way reasoning text does. */ [data-slot="tool-approval-line"] { display: flex; flex-wrap: wrap; @@ -518,7 +518,6 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty padding: 4px 0 6px; font-family: var(--font-family-sans); font-size: var(--font-size-small); - font-style: italic; line-height: var(--line-height-normal); color: var(--text-weak); opacity: 0.9; @@ -528,6 +527,11 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty color: var(--text-weak); } + [data-slot="tool-approval-decision"] { + font-weight: var(--font-weight-medium); + color: var(--text-strong); + } + [data-slot="tool-approval-rule"] { font-family: var(--font-family-mono); } From 4feb0c903d9e193dace32f7a1361135ce2b2c4a1 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Fri, 7 Aug 2026 19:29:13 +0200 Subject: [PATCH 14/34] fix(vscode): preserve approval metadata when slimming tool parts for the webview --- .../src/kilo-provider/slim-metadata.ts | 4 ++++ .../tests/unit/slim-metadata.test.ts | 20 +++++++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts b/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts index df8d576e030..9175ea673dc 100644 --- a/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts +++ b/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts @@ -73,6 +73,7 @@ function slimEdit(state: Record): Record { } } if (meta.diagnostics) result.diagnostics = meta.diagnostics + if (meta.approval) result.approval = meta.approval next.metadata = result return next } @@ -84,6 +85,7 @@ function slimPatch(state: Record): Record { if (isObj(meta)) { const slim: Record = {} if (meta.diagnostics) slim.diagnostics = meta.diagnostics + if (meta.approval) slim.approval = meta.approval if (Array.isArray(meta.files)) { slim.files = (meta.files as Record[]).map((f) => { const diff = patch(f.patch) ?? patch(f.diff) @@ -115,6 +117,7 @@ function slimMultiedit(state: Record): Record if (isObj(meta)) { const slim: Record = {} if (meta.diagnostics) slim.diagnostics = meta.diagnostics + if (meta.approval) slim.approval = meta.approval if (Array.isArray(meta.results)) { slim.results = (meta.results as Record[]).map((r) => { const rs: Record = {} @@ -149,6 +152,7 @@ function slimWrite(state: Record): Record { if (meta.filepath) slim.filepath = meta.filepath if (meta.exists !== undefined) slim.exists = meta.exists if (meta.diagnostics) slim.diagnostics = meta.diagnostics + if (meta.approval) slim.approval = meta.approval const fd = meta.filediff if (isObj(fd)) { slim.filediff = { diff --git a/packages/kilo-vscode/tests/unit/slim-metadata.test.ts b/packages/kilo-vscode/tests/unit/slim-metadata.test.ts index 21c62f4722c..96ee681858a 100644 --- a/packages/kilo-vscode/tests/unit/slim-metadata.test.ts +++ b/packages/kilo-vscode/tests/unit/slim-metadata.test.ts @@ -30,6 +30,10 @@ const BIG = "x".repeat(200_000) // 200 KB — typical file content size const DIAG = [ { range: { start: { line: 1, character: 0 }, end: { line: 1, character: 5 } }, message: "err", severity: 1 }, ] +// Regression for #13001: slimmers used to rebuild `metadata` from an explicit allowlist that +// didn't include `approval`, silently dropping the auto-approval reason (and the +// outside-workspace note) before it ever reached the webview. +const APPROVAL = { source: "agent", agent: "code", outsideWorkspace: true, outsideWorkspacePath: "/tmp/a.ts" } // --------------------------------------------------------------------------- // Tests @@ -96,6 +100,7 @@ describe("slimPart", () => { diff: BIG, filediff: { file: "/a.ts", patch: PATCH, before: BIG, after: BIG, additions: 3, deletions: 1 }, diagnostics: { "/a.ts": DIAG }, + approval: APPROVAL, }, }) @@ -103,7 +108,7 @@ describe("slimPart", () => { expect(bytes(slimPart(heavy))).toBeLessThan(MAX_SLIM_BYTES) }) - it("keeps filediff counts and diagnostics", () => { + it("keeps filediff counts, diagnostics, and approval", () => { const slim = slimPart(heavy) as Record const meta = slim.state.metadata expect(meta.filediff.file).toBe("/a.ts") @@ -111,6 +116,7 @@ describe("slimPart", () => { expect(meta.filediff.additions).toBe(3) expect(meta.filediff.deletions).toBe(1) expect(meta.diagnostics).toEqual({ "/a.ts": DIAG }) + expect(meta.approval).toEqual(APPROVAL) }) it("keeps output and input intact", () => { @@ -176,6 +182,7 @@ describe("slimPart", () => { }, ], diagnostics: { "/a.ts": DIAG }, + approval: APPROVAL, }, }) @@ -183,7 +190,7 @@ describe("slimPart", () => { expect(bytes(slimPart(heavy))).toBeLessThan(MAX_SLIM_BYTES) }) - it("keeps file summary fields and diagnostics", () => { + it("keeps file summary fields, diagnostics, and approval", () => { const slim = slimPart(heavy) as Record const meta = slim.state.metadata expect(meta.files[0].filePath).toBe("/a.ts") @@ -193,6 +200,7 @@ describe("slimPart", () => { expect(meta.files[0].additions).toBe(5) expect(meta.files[1].type).toBe("add") expect(meta.diagnostics).toEqual({ "/a.ts": DIAG }) + expect(meta.approval).toEqual(APPROVAL) }) it("drops unknown heavy metadata fields", () => { @@ -266,6 +274,7 @@ describe("slimPart", () => { }, { filediff: { file: "/b.ts", before: BIG, after: BIG, additions: 2, deletions: 0 }, diagnostics: {} }, ], + approval: APPROVAL, }, }) @@ -273,7 +282,7 @@ describe("slimPart", () => { expect(bytes(slimPart(heavy))).toBeLessThan(MAX_SLIM_BYTES) }) - it("keeps filediff counts and per-result diagnostics", () => { + it("keeps filediff counts, per-result diagnostics, and approval", () => { const slim = slimPart(heavy) as Record const meta = slim.state.metadata expect(meta.results[0].filediff.file).toBe("/a.ts") @@ -282,6 +291,7 @@ describe("slimPart", () => { expect(meta.results[0].diagnostics).toEqual({ "/a.ts": DIAG }) expect(meta.results[1].filediff.file).toBe("/b.ts") expect(meta.diagnostics).toEqual({ "/a.ts": DIAG }) + expect(meta.approval).toEqual(APPROVAL) }) it("drops unknown heavy metadata fields", () => { @@ -318,6 +328,7 @@ describe("slimPart", () => { diff: BIG, filediff: { file: "/a.ts", patch: PATCH, before: BIG, after: BIG, additions: 100, deletions: 0 }, diagnostics: { "/a.ts": DIAG }, + approval: APPROVAL, }, }) @@ -325,7 +336,7 @@ describe("slimPart", () => { expect(bytes(slimPart(heavy))).toBeLessThan(MAX_SLIM_BYTES) }) - it("keeps filepath, exists, filediff counts, diagnostics", () => { + it("keeps filepath, exists, filediff counts, diagnostics, and approval", () => { const slim = slimPart(heavy) as Record const meta = slim.state.metadata expect(meta.filepath).toBe("/a.ts") @@ -335,6 +346,7 @@ describe("slimPart", () => { expect(meta.filediff.additions).toBe(100) expect(meta.filediff.deletions).toBe(0) expect(meta.diagnostics).toEqual({ "/a.ts": DIAG }) + expect(meta.approval).toEqual(APPROVAL) }) it("drops unknown heavy metadata fields", () => { From 9dbf276adec8a6b88e451eb13422272e16435cbe Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:26:07 +0000 Subject: [PATCH 15/34] feat(llm): set explicit prompt cache breakpoints on stable prefix for GPT-5.6+ --- ...penai-explicit-prompt-cache-breakpoints.md | 5 ++ packages/llm/src/cache-policy.ts | 14 ++- packages/llm/src/protocols/openai-chat.ts | 87 ++++++++++++++++--- .../llm/src/protocols/openai-responses.ts | 69 +++++++++++++-- packages/llm/test/cache-policy.test.ts | 84 +++++++++++++++++- packages/opencode/src/provider/transform.ts | 35 +++++++- .../opencode/test/provider/transform.test.ts | 64 ++++++++++++++ 7 files changed, 335 insertions(+), 23 deletions(-) create mode 100644 .changeset/openai-explicit-prompt-cache-breakpoints.md diff --git a/.changeset/openai-explicit-prompt-cache-breakpoints.md b/.changeset/openai-explicit-prompt-cache-breakpoints.md new file mode 100644 index 00000000000..4ba3d97b450 --- /dev/null +++ b/.changeset/openai-explicit-prompt-cache-breakpoints.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Set explicit prompt cache breakpoints on stable prefixes for OpenAI GPT-5.6+ models. diff --git a/packages/llm/src/cache-policy.ts b/packages/llm/src/cache-policy.ts index 60f96dc69aa..3387c3a67d0 100644 --- a/packages/llm/src/cache-policy.ts +++ b/packages/llm/src/cache-policy.ts @@ -36,10 +36,16 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => { return policy } -// Protocols whose wire format ignores inline cache markers (OpenAI's implicit -// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the -// whole policy pass for these — emitting hints would be harmless but pointless. -const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"]) +// kilocode_change start - Protocols whose wire format supports inline cache markers / explicit breakpoints. +// Gemini uses out-of-band CachedContent. +const RESPECTS_INLINE_HINTS = new Set([ + "anthropic-messages", + "bedrock-converse", + "openai-responses", + "openai-chat", + "openai-compatible-chat", +]) +// kilocode_change end const makeHint = (ttlSeconds: number | undefined): CacheHint => ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" }) diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index c6ac16c7b3b..7d672563e5a 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -28,6 +28,24 @@ const IMAGE_MIMES = new Set(ProviderShared.IMAGE_MIMES) export const DEFAULT_BASE_URL = "https://api.openai.com/v1" export const PATH = "/chat/completions" +// kilocode_change start - explicit prompt cache breakpoints for GPT-5.6+ +const supportsBreakpoint = (modelId: string) => { + const match = modelId.match(/gpt-(\d+)\.(\d+)/) + if (match) { + const major = Number(match[1]) + const minor = Number(match[2]) + if (major > 5 || (major === 5 && minor >= 6)) return true + } + const majorMatch = modelId.match(/gpt-(\d+)/) + if (majorMatch && Number(majorMatch[1]) >= 6) return true + return false +} + +const OpenAIChatPromptCacheBreakpoint = Schema.Struct({ + mode: Schema.Literal("explicit"), +}) +// kilocode_change end + // ============================================================================= // Request Body Schema // ============================================================================= @@ -57,15 +75,29 @@ const OpenAIChatAssistantToolCall = Schema.Struct({ type OpenAIChatAssistantToolCall = Schema.Schema.Type const OpenAIChatUserContent = Schema.Union([ - Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }), + Schema.Struct({ + type: Schema.Literal("text"), + text: Schema.String, + prompt_cache_breakpoint: Schema.optional(OpenAIChatPromptCacheBreakpoint), // kilocode_change + }), Schema.Struct({ type: Schema.Literal("image_url"), image_url: Schema.Struct({ url: Schema.String }), + prompt_cache_breakpoint: Schema.optional(OpenAIChatPromptCacheBreakpoint), // kilocode_change }), ]) const OpenAIChatMessage = Schema.Union([ - Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }), + // kilocode_change start - support content block array for system/developer messages + Schema.Struct({ + role: Schema.Literal("system"), + content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]), + }), + Schema.Struct({ + role: Schema.Literal("developer"), + content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]), + }), + // kilocode_change end Schema.Struct({ role: Schema.Literal("user"), content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]), @@ -210,21 +242,33 @@ const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart const openAICompatibleReasoningContent = (native: unknown) => isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined -const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) { +const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* ( + message: OpenAIChatRequestMessage, + modelId: string, // kilocode_change +) { const content: Array> = [] for (const part of message.content) { + // kilocode_change start + const breakpoint = + "cache" in part && part.cache && supportsBreakpoint(modelId) + ? { prompt_cache_breakpoint: { mode: "explicit" as const } } + : {} if (part.type === "text") { - content.push({ type: "text", text: part.text }) + content.push({ type: "text", text: part.text, ...breakpoint }) continue } if (part.type === "media") { - content.push(yield* lowerMedia(part)) + content.push({ ...(yield* lowerMedia(part)), ...breakpoint }) continue } + // kilocode_change end return yield* ProviderShared.unsupportedContent("OpenAI Chat", "user", ["text", "media"]) } - if (content.every((part) => part.type === "text")) - return { role: "user" as const, content: content.map((part) => part.text).join("\n") } // kilocode_change + // kilocode_change start + const hasBreakpoint = content.some((part) => "prompt_cache_breakpoint" in part && part.prompt_cache_breakpoint) + if (!hasBreakpoint && content.every((part) => part.type === "text")) + return { role: "user" as const, content: content.map((part) => (part as { text: string }).text).join("\n") } + // kilocode_change end return { role: "user" as const, content } }) @@ -284,15 +328,36 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m return { messages, images } }) -const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (message: OpenAIChatRequestMessage) { - if (message.role === "user") return [yield* lowerUserMessage(message)] +const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* ( + message: OpenAIChatRequestMessage, + modelId: string, // kilocode_change +) { + if (message.role === "user") return [yield* lowerUserMessage(message, modelId)] // kilocode_change if (message.role === "assistant") return [yield* lowerAssistantMessage(message)] return (yield* lowerToolMessages(message)).messages }) const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest) { + // kilocode_change start + const hasSystemCache = request.system.some((part) => part.cache) && supportsBreakpoint(request.model.id) const system: OpenAIChatMessage[] = - request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }] + request.system.length === 0 + ? [] + : hasSystemCache + ? [ + { + role: "system", + content: [ + { + type: "text", + text: ProviderShared.joinText(request.system), + prompt_cache_breakpoint: { mode: "explicit" }, + }, + ], + }, + ] + : [{ role: "system", content: ProviderShared.joinText(request.system) }] + // kilocode_change end const messages = [...system] const pendingImages: Array> = [] const flushImages = () => { @@ -324,7 +389,7 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: continue } flushImages() - messages.push(...(yield* lowerMessage(message))) + messages.push(...(yield* lowerMessage(message, request.model.id))) // kilocode_change } flushImages() return messages diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 2e6b67aec72..cdbb27965f7 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -29,16 +29,36 @@ const ADAPTER = "openai-responses" export const DEFAULT_BASE_URL = "https://api.openai.com/v1" export const PATH = "/responses" +// kilocode_change start - explicit prompt cache breakpoints for GPT-5.6+ +const supportsBreakpoint = (modelId: string) => { + const match = modelId.match(/gpt-(\d+)\.(\d+)/) + if (match) { + const major = Number(match[1]) + const minor = Number(match[2]) + if (major > 5 || (major === 5 && minor >= 6)) return true + } + const majorMatch = modelId.match(/gpt-(\d+)/) + if (majorMatch && Number(majorMatch[1]) >= 6) return true + return false +} + +const OpenAIResponsesPromptCacheBreakpoint = Schema.Struct({ + mode: Schema.Literal("explicit"), +}) +// kilocode_change end + // ============================================================================= // Request Body Schema // ============================================================================= const OpenAIResponsesInputText = Schema.Struct({ type: Schema.tag("input_text"), text: Schema.String, + prompt_cache_breakpoint: Schema.optional(OpenAIResponsesPromptCacheBreakpoint), // kilocode_change }) const OpenAIResponsesInputImage = Schema.Struct({ type: Schema.tag("input_image"), image_url: Schema.String, + prompt_cache_breakpoint: Schema.optional(OpenAIResponsesPromptCacheBreakpoint), // kilocode_change }) const OpenAIResponsesInputContent = Schema.Union([OpenAIResponsesInputText, OpenAIResponsesInputImage]) type OpenAIResponsesInputContent = Schema.Schema.Type @@ -76,7 +96,16 @@ const OpenAIResponsesFunctionCallOutput = Schema.Union([ ]) const OpenAIResponsesInputItem = Schema.Union([ - Schema.Struct({ role: Schema.tag("system"), content: Schema.String }), + // kilocode_change start - support content block array for system/developer messages + Schema.Struct({ + role: Schema.Literal("system"), + content: Schema.Union([Schema.String, Schema.Array(OpenAIResponsesInputContent)]), + }), + Schema.Struct({ + role: Schema.Literal("developer"), + content: Schema.Union([Schema.String, Schema.Array(OpenAIResponsesInputContent)]), + }), + // kilocode_change end Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }), Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }), OpenAIResponsesReasoningItem, @@ -307,16 +336,23 @@ const hostedToolItemID = (part: ToolResultPart) => { const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function* ( part: LLMRequest["messages"][number]["content"][number], + modelId: string, // kilocode_change ) { - if (part.type === "text") return { type: "input_text" as const, text: part.text } + // kilocode_change start + const breakpoint = + "cache" in part && part.cache && supportsBreakpoint(modelId) + ? { prompt_cache_breakpoint: { mode: "explicit" as const } } + : {} + if (part.type === "text") return { type: "input_text" as const, text: part.text, ...breakpoint } if (part.type === "media") { const media = yield* ProviderShared.validateMedia( "OpenAI Responses", part, new Set(ProviderShared.IMAGE_MIMES), ) - return { type: "input_image" as const, image_url: media.dataUrl } + return { type: "input_image" as const, image_url: media.dataUrl, ...breakpoint } } + // kilocode_change end return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text", "media"]) }) @@ -344,8 +380,26 @@ const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput") }) const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) { + // kilocode_change start + const hasSystemCache = request.system.some((part) => part.cache) && supportsBreakpoint(request.model.id) const system: OpenAIResponsesInputItem[] = - request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }] + request.system.length === 0 + ? [] + : hasSystemCache + ? [ + { + role: "system", + content: [ + { + type: "input_text", + text: ProviderShared.joinText(request.system), + prompt_cache_breakpoint: { mode: "explicit" }, + }, + ], + }, + ] + : [{ role: "system", content: ProviderShared.joinText(request.system) }] + // kilocode_change end const input: OpenAIResponsesInputItem[] = [...system] const store = OpenAIOptions.store(request) @@ -363,7 +417,12 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ } if (message.role === "user") { - input.push({ role: "user", content: yield* Effect.forEach(message.content, lowerUserContent) }) + // kilocode_change start + input.push({ + role: "user", + content: yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request.model.id)), + }) + // kilocode_change end continue } diff --git a/packages/llm/test/cache-policy.test.ts b/packages/llm/test/cache-policy.test.ts index a126d9502c5..91a6712d108 100644 --- a/packages/llm/test/cache-policy.test.ts +++ b/packages/llm/test/cache-policy.test.ts @@ -6,6 +6,9 @@ import { AmazonBedrock } from "../src/providers" import * as AnthropicMessages from "../src/protocols/anthropic-messages" import * as Gemini from "../src/protocols/gemini" import * as OpenAIChat from "../src/protocols/openai-chat" +// kilocode_change start +import * as OpenAIResponses from "../src/protocols/openai-responses" +// kilocode_change end import { applyCachePolicy } from "../src/cache-policy" import { it } from "./lib/effect" @@ -21,6 +24,16 @@ const openaiModel = OpenAIChat.route .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) .model({ id: "gpt-4o-mini" }) +// kilocode_change start +const openaiGpt56ResponsesModel = OpenAIResponses.route + .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) + .model({ id: "gpt-5.6" }) + +const openaiGpt56ChatModel = OpenAIChat.route + .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) + .model({ id: "gpt-5.6" }) +// kilocode_change end + const geminiModel = Gemini.route .with({ endpoint: { baseURL: "https://generativelanguage.test/v1beta/" }, @@ -79,7 +92,8 @@ describe("applyCachePolicy", () => { }), ) - it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () => + // kilocode_change start + it.effect("'auto' does not emit explicit breakpoints on pre-5.6 OpenAI models", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( LLM.request({ @@ -91,13 +105,79 @@ describe("applyCachePolicy", () => { ) const body = prepared.body as { messages: Array<{ content: unknown }> } - // OpenAI doesn't accept cache_control on messages — policy must skip. + // Older OpenAI models reject prompt_cache_breakpoint — policy must skip. const flat = JSON.stringify(body) + expect(flat).not.toContain("prompt_cache_breakpoint") expect(flat).not.toContain("cache_control") expect(flat).not.toContain("cachePoint") }), ) + it.effect("'auto' emits prompt_cache_breakpoint on stable system prefix and latest user on GPT-5.6 Responses", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: openaiGpt56ResponsesModel, + system: "System instructions", + messages: [ + Message.user("first question"), + Message.assistant("assistant reply"), + Message.user("latest question"), + ], + cache: "auto", + }), + ) + + expect(prepared.body).toMatchObject({ + input: [ + { + role: "system", + content: [{ type: "input_text", text: "System instructions", prompt_cache_breakpoint: { mode: "explicit" } }], + }, + { role: "user", content: [{ type: "input_text", text: "first question" }] }, + { role: "assistant", content: [{ type: "output_text", text: "assistant reply" }] }, + { + role: "user", + content: [{ type: "input_text", text: "latest question", prompt_cache_breakpoint: { mode: "explicit" } }], + }, + ], + }) + }), + ) + + it.effect("'auto' emits prompt_cache_breakpoint on stable system prefix and latest user on GPT-5.6 Chat", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: openaiGpt56ChatModel, + system: "System instructions", + messages: [ + Message.user("first question"), + Message.assistant("assistant reply"), + Message.user("latest question"), + ], + cache: "auto", + }), + ) + + expect(prepared.body).toMatchObject({ + messages: [ + { + role: "system", + content: [{ type: "text", text: "System instructions", prompt_cache_breakpoint: { mode: "explicit" } }], + }, + { role: "user", content: "first question" }, + { role: "assistant", content: "assistant reply" }, + { + role: "user", + content: [{ type: "text", text: "latest question", prompt_cache_breakpoint: { mode: "explicit" } }], + }, + ], + }) + }), + ) + // kilocode_change end + it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 8112996020f..4b1c28e6f5f 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -328,6 +328,20 @@ function normalizeMessages( return msgs } +// kilocode_change start - explicit prompt cache breakpoints for GPT-5.6+ +function supportsOpenAICacheBreakpoint(modelId: string): boolean { + const match = modelId.match(/gpt-(\d+)\.(\d+)/) + if (match) { + const major = Number(match[1]) + const minor = Number(match[2]) + if (major > 5 || (major === 5 && minor >= 6)) return true + } + const majorMatch = modelId.match(/gpt-(\d+)/) + if (majorMatch && Number(majorMatch[1]) >= 6) return true + return false +} +// kilocode_change end + function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] { const system = msgs.filter((msg) => msg.role === "system").slice(0, 2) const final = msgs.filter((msg) => msg.role !== "system").slice(-2) @@ -351,6 +365,18 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage alibaba: { cacheControl: { type: "ephemeral" }, }, + // kilocode_change start + ...(supportsOpenAICacheBreakpoint(model.api.id) + ? { + openai: { + promptCacheBreakpoint: { mode: "explicit" }, + }, + azure: { + promptCacheBreakpoint: { mode: "explicit" }, + }, + } + : {}), + // kilocode_change end } for (const msg of unique([...system, ...final])) { @@ -438,6 +464,7 @@ function mapProviderOptions( export function message(msgs: ModelMessage[], model: Provider.Model, options: Record) { msgs = unsupportedParts(msgs, model) msgs = normalizeMessages(msgs, model, options) + // kilocode_change start - apply caching for anthropic, alibaba, and GPT-5.6+ openai/azure if ( (model.providerID === "anthropic" || model.providerID === "google-vertex-anthropic" || @@ -446,11 +473,17 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re model.id.includes("anthropic") || model.id.includes("claude") || model.api.npm === "@ai-sdk/anthropic" || - model.api.npm === "@ai-sdk/alibaba") && + model.api.npm === "@ai-sdk/alibaba" || + ((model.api.npm === "@ai-sdk/openai" || + model.api.npm === "@ai-sdk/azure" || + model.providerID === "openai" || + model.providerID === "azure") && + supportsOpenAICacheBreakpoint(model.api.id))) && model.api.npm !== "@ai-sdk/gateway" ) { msgs = applyCaching(msgs, model) } + // kilocode_change end // Remap providerOptions keys from stored providerID to expected SDK key const key = sdkKey(model.api.npm) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index dfa901724b1..14542cdaa41 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3029,6 +3029,70 @@ describe("ProviderTransform.message - cache control on gateway", () => { }, }) }) + + // kilocode_change start + test("openai gpt-5.6 applies promptCacheBreakpoint", () => { + const model = createModel({ + providerID: "openai", + api: { + id: "gpt-5.6", + url: "https://api.openai.com/v1", + npm: "@ai-sdk/openai", + }, + id: "gpt-5.6", + }) + const msgs = [ + { + role: "system", + content: "You are a helpful assistant", + }, + { + role: "user", + content: "Hello", + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) as any[] + + expect(result[0].providerOptions.openai).toEqual({ + promptCacheBreakpoint: { + mode: "explicit", + }, + }) + expect(result[1].providerOptions.openai).toEqual({ + promptCacheBreakpoint: { + mode: "explicit", + }, + }) + }) + + test("openai pre-5.6 does not apply promptCacheBreakpoint", () => { + const model = createModel({ + providerID: "openai", + api: { + id: "gpt-4o", + url: "https://api.openai.com/v1", + npm: "@ai-sdk/openai", + }, + id: "gpt-4o", + }) + const msgs = [ + { + role: "system", + content: "You are a helpful assistant", + }, + { + role: "user", + content: "Hello", + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) as any[] + + expect(result[0].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() + expect(result[1].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() + }) + // kilocode_change end }) describe("ProviderTransform.temperature - Cohere North", () => { From 435c029f8cad49af70be2dd1857a85636d4f406b Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:39:22 +0000 Subject: [PATCH 16/34] refactor(llm): extract shared supportsPromptCacheBreakpoint and restrict inline hints to native openai routes Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- packages/llm/src/cache-policy.ts | 1 - packages/llm/src/index.ts | 1 + packages/llm/src/protocols/openai-chat.ts | 16 ++-------------- .../llm/src/protocols/openai-responses.ts | 16 ++-------------- .../llm/src/protocols/utils/openai-options.ts | 14 ++++++++++++++ packages/opencode/src/provider/transform.ts | 19 +++---------------- 6 files changed, 22 insertions(+), 45 deletions(-) diff --git a/packages/llm/src/cache-policy.ts b/packages/llm/src/cache-policy.ts index 3387c3a67d0..e1caf0d67e5 100644 --- a/packages/llm/src/cache-policy.ts +++ b/packages/llm/src/cache-policy.ts @@ -43,7 +43,6 @@ const RESPECTS_INLINE_HINTS = new Set([ "bedrock-converse", "openai-responses", "openai-chat", - "openai-compatible-chat", ]) // kilocode_change end diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index 735520ff77c..1a9fd1fd3de 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -26,6 +26,7 @@ export type { ToolToModelOutput, } from "./tool" export * as LLM from "./llm" +export { supportsPromptCacheBreakpoint } from "./protocols/utils/openai-options" // kilocode_change export type { Definition as ProviderDefinition, ModelFactory as ProviderModelFactory, diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index 7d672563e5a..9db95915669 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -29,18 +29,6 @@ export const DEFAULT_BASE_URL = "https://api.openai.com/v1" export const PATH = "/chat/completions" // kilocode_change start - explicit prompt cache breakpoints for GPT-5.6+ -const supportsBreakpoint = (modelId: string) => { - const match = modelId.match(/gpt-(\d+)\.(\d+)/) - if (match) { - const major = Number(match[1]) - const minor = Number(match[2]) - if (major > 5 || (major === 5 && minor >= 6)) return true - } - const majorMatch = modelId.match(/gpt-(\d+)/) - if (majorMatch && Number(majorMatch[1]) >= 6) return true - return false -} - const OpenAIChatPromptCacheBreakpoint = Schema.Struct({ mode: Schema.Literal("explicit"), }) @@ -250,7 +238,7 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* ( for (const part of message.content) { // kilocode_change start const breakpoint = - "cache" in part && part.cache && supportsBreakpoint(modelId) + "cache" in part && part.cache && OpenAIOptions.supportsPromptCacheBreakpoint(modelId) ? { prompt_cache_breakpoint: { mode: "explicit" as const } } : {} if (part.type === "text") { @@ -339,7 +327,7 @@ const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* ( const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest) { // kilocode_change start - const hasSystemCache = request.system.some((part) => part.cache) && supportsBreakpoint(request.model.id) + const hasSystemCache = request.system.some((part) => part.cache) && OpenAIOptions.supportsPromptCacheBreakpoint(request.model.id) const system: OpenAIChatMessage[] = request.system.length === 0 ? [] diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index cdbb27965f7..74b4524518d 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -30,18 +30,6 @@ export const DEFAULT_BASE_URL = "https://api.openai.com/v1" export const PATH = "/responses" // kilocode_change start - explicit prompt cache breakpoints for GPT-5.6+ -const supportsBreakpoint = (modelId: string) => { - const match = modelId.match(/gpt-(\d+)\.(\d+)/) - if (match) { - const major = Number(match[1]) - const minor = Number(match[2]) - if (major > 5 || (major === 5 && minor >= 6)) return true - } - const majorMatch = modelId.match(/gpt-(\d+)/) - if (majorMatch && Number(majorMatch[1]) >= 6) return true - return false -} - const OpenAIResponsesPromptCacheBreakpoint = Schema.Struct({ mode: Schema.Literal("explicit"), }) @@ -340,7 +328,7 @@ const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function* ) { // kilocode_change start const breakpoint = - "cache" in part && part.cache && supportsBreakpoint(modelId) + "cache" in part && part.cache && OpenAIOptions.supportsPromptCacheBreakpoint(modelId) ? { prompt_cache_breakpoint: { mode: "explicit" as const } } : {} if (part.type === "text") return { type: "input_text" as const, text: part.text, ...breakpoint } @@ -381,7 +369,7 @@ const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput") const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) { // kilocode_change start - const hasSystemCache = request.system.some((part) => part.cache) && supportsBreakpoint(request.model.id) + const hasSystemCache = request.system.some((part) => part.cache) && OpenAIOptions.supportsPromptCacheBreakpoint(request.model.id) const system: OpenAIResponsesInputItem[] = request.system.length === 0 ? [] diff --git a/packages/llm/src/protocols/utils/openai-options.ts b/packages/llm/src/protocols/utils/openai-options.ts index 59ac7abc874..708dc07d28c 100644 --- a/packages/llm/src/protocols/utils/openai-options.ts +++ b/packages/llm/src/protocols/utils/openai-options.ts @@ -101,4 +101,18 @@ export const instructions = (request: LLMRequest) => { return typeof value === "string" ? value : undefined } +// kilocode_change start - explicit prompt cache breakpoints for GPT-5.6+ +export const supportsPromptCacheBreakpoint = (modelId: string): boolean => { + const match = modelId.match(/gpt-(\d+)\.(\d+)/) + if (match) { + const major = Number(match[1]) + const minor = Number(match[2]) + if (major > 5 || (major === 5 && minor >= 6)) return true + } + const majorMatch = modelId.match(/gpt-(\d+)/) + if (majorMatch && Number(majorMatch[1]) >= 6) return true + return false +} +// kilocode_change end + export * as OpenAIOptions from "./openai-options" diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 4b1c28e6f5f..dbe3f58da25 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1,6 +1,7 @@ import type { ModelMessage, ToolResultPart } from "ai" import { mergeDeep, unique } from "remeda" import type { JSONSchema7 } from "@ai-sdk/provider" +import { supportsPromptCacheBreakpoint } from "@opencode-ai/llm" // kilocode_change import type * as Provider from "./provider" import type * as ModelsDev from "@opencode-ai/core/models-dev" import { iife } from "@/util/iife" @@ -328,20 +329,6 @@ function normalizeMessages( return msgs } -// kilocode_change start - explicit prompt cache breakpoints for GPT-5.6+ -function supportsOpenAICacheBreakpoint(modelId: string): boolean { - const match = modelId.match(/gpt-(\d+)\.(\d+)/) - if (match) { - const major = Number(match[1]) - const minor = Number(match[2]) - if (major > 5 || (major === 5 && minor >= 6)) return true - } - const majorMatch = modelId.match(/gpt-(\d+)/) - if (majorMatch && Number(majorMatch[1]) >= 6) return true - return false -} -// kilocode_change end - function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] { const system = msgs.filter((msg) => msg.role === "system").slice(0, 2) const final = msgs.filter((msg) => msg.role !== "system").slice(-2) @@ -366,7 +353,7 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage cacheControl: { type: "ephemeral" }, }, // kilocode_change start - ...(supportsOpenAICacheBreakpoint(model.api.id) + ...(supportsPromptCacheBreakpoint(model.api.id) ? { openai: { promptCacheBreakpoint: { mode: "explicit" }, @@ -478,7 +465,7 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re model.api.npm === "@ai-sdk/azure" || model.providerID === "openai" || model.providerID === "azure") && - supportsOpenAICacheBreakpoint(model.api.id))) && + supportsPromptCacheBreakpoint(model.api.id))) && model.api.npm !== "@ai-sdk/gateway" ) { msgs = applyCaching(msgs, model) From 944ab18304ff4b26574daa546ef745b2c6337efe Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:17:49 +0000 Subject: [PATCH 17/34] fix(llm): place prompt cache breakpoint before trailing environment details Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- packages/llm/src/cache-policy.ts | 21 +++++++-- packages/llm/test/cache-policy.test.ts | 46 +++++++++++++++++++ packages/opencode/src/provider/transform.ts | 28 ++++++++--- .../opencode/test/provider/transform.test.ts | 35 ++++++++++++++ 4 files changed, 120 insertions(+), 10 deletions(-) diff --git a/packages/llm/src/cache-policy.ts b/packages/llm/src/cache-policy.ts index e1caf0d67e5..96cd7b38bdb 100644 --- a/packages/llm/src/cache-policy.ts +++ b/packages/llm/src/cache-policy.ts @@ -66,15 +66,28 @@ const markLastSystem = (system: LLMRequest["system"], hint: CacheHint): LLMReque const lastIndexOfRole = (messages: ReadonlyArray, role: Message["role"]): number => messages.findLastIndex((m) => m.role === role) -// Mark the last text part of `messages[index]`. If no text part exists, mark -// the last content part regardless of type — that's the breakpoint position -// in tool-result-only messages too. +// kilocode_change start - mark the last stable text part of `messages[index]` (skipping trailing ephemeral details) +const isEphemeralPart = (part: ContentPart): boolean => { + if (part.type === "text" && (part.text.startsWith("") || part.text.includes(""))) + return true + if ("metadata" in part && part.metadata && Boolean((part.metadata as Record).synthetic)) + return true + return false +} + const markMessageAt = (messages: ReadonlyArray, index: number, hint: CacheHint): ReadonlyArray => { if (index < 0 || index >= messages.length) return messages const target = messages[index]! if (target.content.length === 0) return messages + const lastStableTextIndex = target.content.findLastIndex((part) => part.type === "text" && !isEphemeralPart(part)) const lastTextIndex = target.content.findLastIndex((part) => part.type === "text") - const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1 + const markAt = + lastStableTextIndex >= 0 + ? lastStableTextIndex + : lastTextIndex >= 0 + ? lastTextIndex + : target.content.length - 1 + // kilocode_change end const existing = target.content[markAt]! if ("cache" in existing && existing.cache) return messages const nextContent = target.content.map((part, i) => (i === markAt ? ({ ...part, cache: hint } as ContentPart) : part)) diff --git a/packages/llm/test/cache-policy.test.ts b/packages/llm/test/cache-policy.test.ts index 91a6712d108..b0f3256e988 100644 --- a/packages/llm/test/cache-policy.test.ts +++ b/packages/llm/test/cache-policy.test.ts @@ -176,6 +176,52 @@ describe("applyCachePolicy", () => { }) }), ) + + it.effect("places prompt_cache_breakpoint BEFORE trailing part", () => + Effect.gen(function* () { + const envBlock = "\nCurrent time: 2026-08-08T18:00:00+00:00\n" + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: openaiGpt56ResponsesModel, + system: "System instructions", + messages: [ + Message.user([ + { type: "text", text: "Please inspect the codebase" }, + { type: "text", text: envBlock }, + ]), + ], + cache: "auto", + }), + ) + + expect(prepared.body).toMatchObject({ + input: [ + { + role: "system", + content: [{ type: "input_text", text: "System instructions", prompt_cache_breakpoint: { mode: "explicit" } }], + }, + { + role: "user", + content: [ + { + type: "input_text", + text: "Please inspect the codebase", + prompt_cache_breakpoint: { mode: "explicit" }, + }, + { + type: "input_text", + text: envBlock, + }, + ], + }, + ], + }) + + const userContent = (prepared.body as any).input[1].content + expect(userContent[0].prompt_cache_breakpoint).toEqual({ mode: "explicit" }) + expect(userContent[1].prompt_cache_breakpoint).toBeUndefined() + }), + ) // kilocode_change end it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () => diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index dbe3f58da25..f0173b50303 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -371,20 +371,36 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage model.providerID === "anthropic" || model.providerID.includes("bedrock") || model.api.npm === "@ai-sdk/amazon-bedrock" + // kilocode_change start - place caching breakpoint on stable content before trailing const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0 if (shouldUseContentOptions) { - const lastContent = msg.content[msg.content.length - 1] + const parts = msg.content as any[] + const targetIndex = parts.findLastIndex( + (part) => + part && + typeof part === "object" && + part.type !== "tool-approval-request" && + part.type !== "tool-approval-response" && + !( + part.type === "text" && + typeof part.text === "string" && + (part.text.startsWith("") || part.text.includes("")) + ) && + !part.synthetic, + ) + const targetContent = targetIndex >= 0 ? parts[targetIndex] : parts[parts.length - 1] if ( - lastContent && - typeof lastContent === "object" && - lastContent.type !== "tool-approval-request" && - lastContent.type !== "tool-approval-response" + targetContent && + typeof targetContent === "object" && + targetContent.type !== "tool-approval-request" && + targetContent.type !== "tool-approval-response" ) { - lastContent.providerOptions = mergeDeep(lastContent.providerOptions ?? {}, providerOptions) + targetContent.providerOptions = mergeDeep(targetContent.providerOptions ?? {}, providerOptions) continue } } + // kilocode_change end msg.providerOptions = mergeDeep(msg.providerOptions ?? {}, providerOptions) } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 14542cdaa41..2f8c7d23b18 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3092,6 +3092,41 @@ describe("ProviderTransform.message - cache control on gateway", () => { expect(result[0].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() expect(result[1].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() }) + + test("openai gpt-5.6 places promptCacheBreakpoint before trailing environment_details", () => { + const model = createModel({ + providerID: "openai", + api: { + id: "gpt-5.6", + url: "https://api.openai.com/v1", + npm: "@ai-sdk/openai", + }, + id: "gpt-5.6", + }) + const envBlock = "\nCurrent time: 2026-08-08T18:00:00+00:00\n" + const msgs = [ + { + role: "system", + content: "You are a helpful assistant", + }, + { + role: "user", + content: [ + { type: "text", text: "Please review the changes" }, + { type: "text", text: envBlock }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) as any[] + + expect(result[1].content[0].providerOptions.openai).toEqual({ + promptCacheBreakpoint: { + mode: "explicit", + }, + }) + expect(result[1].content[1].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() + }) // kilocode_change end }) From 2f72a8b72dc16b226f937384c8fd11a140262e1e Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:22:35 +0000 Subject: [PATCH 18/34] refactor(cli): limit explicit prompt cache breakpoint changes to Vercel AI SDK path Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- packages/llm/src/cache-policy.ts | 34 ++--- packages/llm/src/index.ts | 1 - packages/llm/src/protocols/openai-chat.ts | 75 ++-------- .../llm/src/protocols/openai-responses.ts | 57 +------- .../llm/src/protocols/utils/openai-options.ts | 14 -- packages/llm/test/cache-policy.test.ts | 130 +----------------- packages/opencode/src/provider/transform.ts | 15 +- 7 files changed, 40 insertions(+), 286 deletions(-) diff --git a/packages/llm/src/cache-policy.ts b/packages/llm/src/cache-policy.ts index 96cd7b38bdb..60f96dc69aa 100644 --- a/packages/llm/src/cache-policy.ts +++ b/packages/llm/src/cache-policy.ts @@ -36,15 +36,10 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => { return policy } -// kilocode_change start - Protocols whose wire format supports inline cache markers / explicit breakpoints. -// Gemini uses out-of-band CachedContent. -const RESPECTS_INLINE_HINTS = new Set([ - "anthropic-messages", - "bedrock-converse", - "openai-responses", - "openai-chat", -]) -// kilocode_change end +// Protocols whose wire format ignores inline cache markers (OpenAI's implicit +// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the +// whole policy pass for these — emitting hints would be harmless but pointless. +const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"]) const makeHint = (ttlSeconds: number | undefined): CacheHint => ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" }) @@ -66,28 +61,15 @@ const markLastSystem = (system: LLMRequest["system"], hint: CacheHint): LLMReque const lastIndexOfRole = (messages: ReadonlyArray, role: Message["role"]): number => messages.findLastIndex((m) => m.role === role) -// kilocode_change start - mark the last stable text part of `messages[index]` (skipping trailing ephemeral details) -const isEphemeralPart = (part: ContentPart): boolean => { - if (part.type === "text" && (part.text.startsWith("") || part.text.includes(""))) - return true - if ("metadata" in part && part.metadata && Boolean((part.metadata as Record).synthetic)) - return true - return false -} - +// Mark the last text part of `messages[index]`. If no text part exists, mark +// the last content part regardless of type — that's the breakpoint position +// in tool-result-only messages too. const markMessageAt = (messages: ReadonlyArray, index: number, hint: CacheHint): ReadonlyArray => { if (index < 0 || index >= messages.length) return messages const target = messages[index]! if (target.content.length === 0) return messages - const lastStableTextIndex = target.content.findLastIndex((part) => part.type === "text" && !isEphemeralPart(part)) const lastTextIndex = target.content.findLastIndex((part) => part.type === "text") - const markAt = - lastStableTextIndex >= 0 - ? lastStableTextIndex - : lastTextIndex >= 0 - ? lastTextIndex - : target.content.length - 1 - // kilocode_change end + const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1 const existing = target.content[markAt]! if ("cache" in existing && existing.cache) return messages const nextContent = target.content.map((part, i) => (i === markAt ? ({ ...part, cache: hint } as ContentPart) : part)) diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index 1a9fd1fd3de..735520ff77c 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -26,7 +26,6 @@ export type { ToolToModelOutput, } from "./tool" export * as LLM from "./llm" -export { supportsPromptCacheBreakpoint } from "./protocols/utils/openai-options" // kilocode_change export type { Definition as ProviderDefinition, ModelFactory as ProviderModelFactory, diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index 9db95915669..c6ac16c7b3b 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -28,12 +28,6 @@ const IMAGE_MIMES = new Set(ProviderShared.IMAGE_MIMES) export const DEFAULT_BASE_URL = "https://api.openai.com/v1" export const PATH = "/chat/completions" -// kilocode_change start - explicit prompt cache breakpoints for GPT-5.6+ -const OpenAIChatPromptCacheBreakpoint = Schema.Struct({ - mode: Schema.Literal("explicit"), -}) -// kilocode_change end - // ============================================================================= // Request Body Schema // ============================================================================= @@ -63,29 +57,15 @@ const OpenAIChatAssistantToolCall = Schema.Struct({ type OpenAIChatAssistantToolCall = Schema.Schema.Type const OpenAIChatUserContent = Schema.Union([ - Schema.Struct({ - type: Schema.Literal("text"), - text: Schema.String, - prompt_cache_breakpoint: Schema.optional(OpenAIChatPromptCacheBreakpoint), // kilocode_change - }), + Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }), Schema.Struct({ type: Schema.Literal("image_url"), image_url: Schema.Struct({ url: Schema.String }), - prompt_cache_breakpoint: Schema.optional(OpenAIChatPromptCacheBreakpoint), // kilocode_change }), ]) const OpenAIChatMessage = Schema.Union([ - // kilocode_change start - support content block array for system/developer messages - Schema.Struct({ - role: Schema.Literal("system"), - content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]), - }), - Schema.Struct({ - role: Schema.Literal("developer"), - content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]), - }), - // kilocode_change end + Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }), Schema.Struct({ role: Schema.Literal("user"), content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]), @@ -230,33 +210,21 @@ const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart const openAICompatibleReasoningContent = (native: unknown) => isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined -const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* ( - message: OpenAIChatRequestMessage, - modelId: string, // kilocode_change -) { +const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) { const content: Array> = [] for (const part of message.content) { - // kilocode_change start - const breakpoint = - "cache" in part && part.cache && OpenAIOptions.supportsPromptCacheBreakpoint(modelId) - ? { prompt_cache_breakpoint: { mode: "explicit" as const } } - : {} if (part.type === "text") { - content.push({ type: "text", text: part.text, ...breakpoint }) + content.push({ type: "text", text: part.text }) continue } if (part.type === "media") { - content.push({ ...(yield* lowerMedia(part)), ...breakpoint }) + content.push(yield* lowerMedia(part)) continue } - // kilocode_change end return yield* ProviderShared.unsupportedContent("OpenAI Chat", "user", ["text", "media"]) } - // kilocode_change start - const hasBreakpoint = content.some((part) => "prompt_cache_breakpoint" in part && part.prompt_cache_breakpoint) - if (!hasBreakpoint && content.every((part) => part.type === "text")) - return { role: "user" as const, content: content.map((part) => (part as { text: string }).text).join("\n") } - // kilocode_change end + if (content.every((part) => part.type === "text")) + return { role: "user" as const, content: content.map((part) => part.text).join("\n") } // kilocode_change return { role: "user" as const, content } }) @@ -316,36 +284,15 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m return { messages, images } }) -const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* ( - message: OpenAIChatRequestMessage, - modelId: string, // kilocode_change -) { - if (message.role === "user") return [yield* lowerUserMessage(message, modelId)] // kilocode_change +const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (message: OpenAIChatRequestMessage) { + if (message.role === "user") return [yield* lowerUserMessage(message)] if (message.role === "assistant") return [yield* lowerAssistantMessage(message)] return (yield* lowerToolMessages(message)).messages }) const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest) { - // kilocode_change start - const hasSystemCache = request.system.some((part) => part.cache) && OpenAIOptions.supportsPromptCacheBreakpoint(request.model.id) const system: OpenAIChatMessage[] = - request.system.length === 0 - ? [] - : hasSystemCache - ? [ - { - role: "system", - content: [ - { - type: "text", - text: ProviderShared.joinText(request.system), - prompt_cache_breakpoint: { mode: "explicit" }, - }, - ], - }, - ] - : [{ role: "system", content: ProviderShared.joinText(request.system) }] - // kilocode_change end + request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }] const messages = [...system] const pendingImages: Array> = [] const flushImages = () => { @@ -377,7 +324,7 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: continue } flushImages() - messages.push(...(yield* lowerMessage(message, request.model.id))) // kilocode_change + messages.push(...(yield* lowerMessage(message))) } flushImages() return messages diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 74b4524518d..2e6b67aec72 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -29,24 +29,16 @@ const ADAPTER = "openai-responses" export const DEFAULT_BASE_URL = "https://api.openai.com/v1" export const PATH = "/responses" -// kilocode_change start - explicit prompt cache breakpoints for GPT-5.6+ -const OpenAIResponsesPromptCacheBreakpoint = Schema.Struct({ - mode: Schema.Literal("explicit"), -}) -// kilocode_change end - // ============================================================================= // Request Body Schema // ============================================================================= const OpenAIResponsesInputText = Schema.Struct({ type: Schema.tag("input_text"), text: Schema.String, - prompt_cache_breakpoint: Schema.optional(OpenAIResponsesPromptCacheBreakpoint), // kilocode_change }) const OpenAIResponsesInputImage = Schema.Struct({ type: Schema.tag("input_image"), image_url: Schema.String, - prompt_cache_breakpoint: Schema.optional(OpenAIResponsesPromptCacheBreakpoint), // kilocode_change }) const OpenAIResponsesInputContent = Schema.Union([OpenAIResponsesInputText, OpenAIResponsesInputImage]) type OpenAIResponsesInputContent = Schema.Schema.Type @@ -84,16 +76,7 @@ const OpenAIResponsesFunctionCallOutput = Schema.Union([ ]) const OpenAIResponsesInputItem = Schema.Union([ - // kilocode_change start - support content block array for system/developer messages - Schema.Struct({ - role: Schema.Literal("system"), - content: Schema.Union([Schema.String, Schema.Array(OpenAIResponsesInputContent)]), - }), - Schema.Struct({ - role: Schema.Literal("developer"), - content: Schema.Union([Schema.String, Schema.Array(OpenAIResponsesInputContent)]), - }), - // kilocode_change end + Schema.Struct({ role: Schema.tag("system"), content: Schema.String }), Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }), Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }), OpenAIResponsesReasoningItem, @@ -324,23 +307,16 @@ const hostedToolItemID = (part: ToolResultPart) => { const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function* ( part: LLMRequest["messages"][number]["content"][number], - modelId: string, // kilocode_change ) { - // kilocode_change start - const breakpoint = - "cache" in part && part.cache && OpenAIOptions.supportsPromptCacheBreakpoint(modelId) - ? { prompt_cache_breakpoint: { mode: "explicit" as const } } - : {} - if (part.type === "text") return { type: "input_text" as const, text: part.text, ...breakpoint } + if (part.type === "text") return { type: "input_text" as const, text: part.text } if (part.type === "media") { const media = yield* ProviderShared.validateMedia( "OpenAI Responses", part, new Set(ProviderShared.IMAGE_MIMES), ) - return { type: "input_image" as const, image_url: media.dataUrl, ...breakpoint } + return { type: "input_image" as const, image_url: media.dataUrl } } - // kilocode_change end return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text", "media"]) }) @@ -368,26 +344,8 @@ const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput") }) const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) { - // kilocode_change start - const hasSystemCache = request.system.some((part) => part.cache) && OpenAIOptions.supportsPromptCacheBreakpoint(request.model.id) const system: OpenAIResponsesInputItem[] = - request.system.length === 0 - ? [] - : hasSystemCache - ? [ - { - role: "system", - content: [ - { - type: "input_text", - text: ProviderShared.joinText(request.system), - prompt_cache_breakpoint: { mode: "explicit" }, - }, - ], - }, - ] - : [{ role: "system", content: ProviderShared.joinText(request.system) }] - // kilocode_change end + request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }] const input: OpenAIResponsesInputItem[] = [...system] const store = OpenAIOptions.store(request) @@ -405,12 +363,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ } if (message.role === "user") { - // kilocode_change start - input.push({ - role: "user", - content: yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request.model.id)), - }) - // kilocode_change end + input.push({ role: "user", content: yield* Effect.forEach(message.content, lowerUserContent) }) continue } diff --git a/packages/llm/src/protocols/utils/openai-options.ts b/packages/llm/src/protocols/utils/openai-options.ts index 708dc07d28c..59ac7abc874 100644 --- a/packages/llm/src/protocols/utils/openai-options.ts +++ b/packages/llm/src/protocols/utils/openai-options.ts @@ -101,18 +101,4 @@ export const instructions = (request: LLMRequest) => { return typeof value === "string" ? value : undefined } -// kilocode_change start - explicit prompt cache breakpoints for GPT-5.6+ -export const supportsPromptCacheBreakpoint = (modelId: string): boolean => { - const match = modelId.match(/gpt-(\d+)\.(\d+)/) - if (match) { - const major = Number(match[1]) - const minor = Number(match[2]) - if (major > 5 || (major === 5 && minor >= 6)) return true - } - const majorMatch = modelId.match(/gpt-(\d+)/) - if (majorMatch && Number(majorMatch[1]) >= 6) return true - return false -} -// kilocode_change end - export * as OpenAIOptions from "./openai-options" diff --git a/packages/llm/test/cache-policy.test.ts b/packages/llm/test/cache-policy.test.ts index b0f3256e988..a126d9502c5 100644 --- a/packages/llm/test/cache-policy.test.ts +++ b/packages/llm/test/cache-policy.test.ts @@ -6,9 +6,6 @@ import { AmazonBedrock } from "../src/providers" import * as AnthropicMessages from "../src/protocols/anthropic-messages" import * as Gemini from "../src/protocols/gemini" import * as OpenAIChat from "../src/protocols/openai-chat" -// kilocode_change start -import * as OpenAIResponses from "../src/protocols/openai-responses" -// kilocode_change end import { applyCachePolicy } from "../src/cache-policy" import { it } from "./lib/effect" @@ -24,16 +21,6 @@ const openaiModel = OpenAIChat.route .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) .model({ id: "gpt-4o-mini" }) -// kilocode_change start -const openaiGpt56ResponsesModel = OpenAIResponses.route - .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) - .model({ id: "gpt-5.6" }) - -const openaiGpt56ChatModel = OpenAIChat.route - .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) - .model({ id: "gpt-5.6" }) -// kilocode_change end - const geminiModel = Gemini.route .with({ endpoint: { baseURL: "https://generativelanguage.test/v1beta/" }, @@ -92,8 +79,7 @@ describe("applyCachePolicy", () => { }), ) - // kilocode_change start - it.effect("'auto' does not emit explicit breakpoints on pre-5.6 OpenAI models", () => + it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( LLM.request({ @@ -105,125 +91,13 @@ describe("applyCachePolicy", () => { ) const body = prepared.body as { messages: Array<{ content: unknown }> } - // Older OpenAI models reject prompt_cache_breakpoint — policy must skip. + // OpenAI doesn't accept cache_control on messages — policy must skip. const flat = JSON.stringify(body) - expect(flat).not.toContain("prompt_cache_breakpoint") expect(flat).not.toContain("cache_control") expect(flat).not.toContain("cachePoint") }), ) - it.effect("'auto' emits prompt_cache_breakpoint on stable system prefix and latest user on GPT-5.6 Responses", () => - Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( - LLM.request({ - model: openaiGpt56ResponsesModel, - system: "System instructions", - messages: [ - Message.user("first question"), - Message.assistant("assistant reply"), - Message.user("latest question"), - ], - cache: "auto", - }), - ) - - expect(prepared.body).toMatchObject({ - input: [ - { - role: "system", - content: [{ type: "input_text", text: "System instructions", prompt_cache_breakpoint: { mode: "explicit" } }], - }, - { role: "user", content: [{ type: "input_text", text: "first question" }] }, - { role: "assistant", content: [{ type: "output_text", text: "assistant reply" }] }, - { - role: "user", - content: [{ type: "input_text", text: "latest question", prompt_cache_breakpoint: { mode: "explicit" } }], - }, - ], - }) - }), - ) - - it.effect("'auto' emits prompt_cache_breakpoint on stable system prefix and latest user on GPT-5.6 Chat", () => - Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( - LLM.request({ - model: openaiGpt56ChatModel, - system: "System instructions", - messages: [ - Message.user("first question"), - Message.assistant("assistant reply"), - Message.user("latest question"), - ], - cache: "auto", - }), - ) - - expect(prepared.body).toMatchObject({ - messages: [ - { - role: "system", - content: [{ type: "text", text: "System instructions", prompt_cache_breakpoint: { mode: "explicit" } }], - }, - { role: "user", content: "first question" }, - { role: "assistant", content: "assistant reply" }, - { - role: "user", - content: [{ type: "text", text: "latest question", prompt_cache_breakpoint: { mode: "explicit" } }], - }, - ], - }) - }), - ) - - it.effect("places prompt_cache_breakpoint BEFORE trailing part", () => - Effect.gen(function* () { - const envBlock = "\nCurrent time: 2026-08-08T18:00:00+00:00\n" - const prepared = yield* LLMClient.prepare( - LLM.request({ - model: openaiGpt56ResponsesModel, - system: "System instructions", - messages: [ - Message.user([ - { type: "text", text: "Please inspect the codebase" }, - { type: "text", text: envBlock }, - ]), - ], - cache: "auto", - }), - ) - - expect(prepared.body).toMatchObject({ - input: [ - { - role: "system", - content: [{ type: "input_text", text: "System instructions", prompt_cache_breakpoint: { mode: "explicit" } }], - }, - { - role: "user", - content: [ - { - type: "input_text", - text: "Please inspect the codebase", - prompt_cache_breakpoint: { mode: "explicit" }, - }, - { - type: "input_text", - text: envBlock, - }, - ], - }, - ], - }) - - const userContent = (prepared.body as any).input[1].content - expect(userContent[0].prompt_cache_breakpoint).toEqual({ mode: "explicit" }) - expect(userContent[1].prompt_cache_breakpoint).toBeUndefined() - }), - ) - // kilocode_change end - it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index f0173b50303..6009891d05e 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1,7 +1,6 @@ import type { ModelMessage, ToolResultPart } from "ai" import { mergeDeep, unique } from "remeda" import type { JSONSchema7 } from "@ai-sdk/provider" -import { supportsPromptCacheBreakpoint } from "@opencode-ai/llm" // kilocode_change import type * as Provider from "./provider" import type * as ModelsDev from "@opencode-ai/core/models-dev" import { iife } from "@/util/iife" @@ -329,6 +328,20 @@ function normalizeMessages( return msgs } +// kilocode_change start - explicit prompt cache breakpoints for GPT-5.6+ +function supportsPromptCacheBreakpoint(modelId: string): boolean { + const match = modelId.match(/gpt-(\d+)\.(\d+)/) + if (match) { + const major = Number(match[1]) + const minor = Number(match[2]) + if (major > 5 || (major === 5 && minor >= 6)) return true + } + const majorMatch = modelId.match(/gpt-(\d+)/) + if (majorMatch && Number(majorMatch[1]) >= 6) return true + return false +} +// kilocode_change end + function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] { const system = msgs.filter((msg) => msg.role === "system").slice(0, 2) const final = msgs.filter((msg) => msg.role !== "system").slice(-2) From 1b0f94ec74e66331a02bcc06c337ed662e1e6412 Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:25:29 +0000 Subject: [PATCH 19/34] feat(cli): enable explicit prompt cache options for OpenAI models on Kilo Gateway Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- packages/opencode/src/provider/transform.ts | 6 ++-- .../opencode/test/provider/transform.test.ts | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 6009891d05e..efb6ef760ad 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -480,7 +480,7 @@ function mapProviderOptions( export function message(msgs: ModelMessage[], model: Provider.Model, options: Record) { msgs = unsupportedParts(msgs, model) msgs = normalizeMessages(msgs, model, options) - // kilocode_change start - apply caching for anthropic, alibaba, and GPT-5.6+ openai/azure + // kilocode_change start - apply caching for anthropic, alibaba, and GPT-5.6+ openai/azure/kilo-gateway if ( (model.providerID === "anthropic" || model.providerID === "google-vertex-anthropic" || @@ -492,8 +492,10 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re model.api.npm === "@ai-sdk/alibaba" || ((model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure" || + model.api.npm === "@kilocode/kilo-gateway" || model.providerID === "openai" || - model.providerID === "azure") && + model.providerID === "azure" || + model.providerID === "kilo") && supportsPromptCacheBreakpoint(model.api.id))) && model.api.npm !== "@ai-sdk/gateway" ) { diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 2f8c7d23b18..74ef174ade8 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3127,6 +3127,41 @@ describe("ProviderTransform.message - cache control on gateway", () => { }) expect(result[1].content[1].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() }) + + test("kilo gateway with openai gpt-5.6 applies caching options", () => { + const model = createModel({ + providerID: "kilo", + api: { + id: "openai/gpt-5.6", + url: "https://api.kilo.ai/api/gateway", + npm: "@kilocode/kilo-gateway", + }, + id: "openai/gpt-5.6", + }) + const msgs = [ + { + role: "system", + content: "You are a helpful assistant", + }, + { + role: "user", + content: "Hello", + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) as any[] + + expect(result[0].providerOptions.openrouter).toEqual({ + cacheControl: { + type: "ephemeral", + }, + }) + expect(result[1].providerOptions.openrouter).toEqual({ + cacheControl: { + type: "ephemeral", + }, + }) + }) // kilocode_change end }) From bca459832456bdba75e48c675122c324568870ea Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:28:46 +0000 Subject: [PATCH 20/34] refactor(cli): simplify provider checks and narrow kilocode_change markers in transform.ts Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- packages/opencode/src/provider/transform.ts | 31 ++++++++------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index efb6ef760ad..d366119a5f3 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -384,10 +384,10 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage model.providerID === "anthropic" || model.providerID.includes("bedrock") || model.api.npm === "@ai-sdk/amazon-bedrock" - // kilocode_change start - place caching breakpoint on stable content before trailing const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0 if (shouldUseContentOptions) { + // kilocode_change start - place caching breakpoint on stable content before trailing const parts = msg.content as any[] const targetIndex = parts.findLastIndex( (part) => @@ -395,25 +395,21 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage typeof part === "object" && part.type !== "tool-approval-request" && part.type !== "tool-approval-response" && - !( - part.type === "text" && - typeof part.text === "string" && - (part.text.startsWith("") || part.text.includes("")) - ) && + !(part.type === "text" && typeof part.text === "string" && part.text.startsWith("")) && !part.synthetic, ) - const targetContent = targetIndex >= 0 ? parts[targetIndex] : parts[parts.length - 1] + const lastContent = targetIndex >= 0 ? parts[targetIndex] : parts[parts.length - 1] + // kilocode_change end if ( - targetContent && - typeof targetContent === "object" && - targetContent.type !== "tool-approval-request" && - targetContent.type !== "tool-approval-response" + lastContent && + typeof lastContent === "object" && + lastContent.type !== "tool-approval-request" && + lastContent.type !== "tool-approval-response" ) { - targetContent.providerOptions = mergeDeep(targetContent.providerOptions ?? {}, providerOptions) + lastContent.providerOptions = mergeDeep(lastContent.providerOptions ?? {}, providerOptions) continue } } - // kilocode_change end msg.providerOptions = mergeDeep(msg.providerOptions ?? {}, providerOptions) } @@ -480,7 +476,6 @@ function mapProviderOptions( export function message(msgs: ModelMessage[], model: Provider.Model, options: Record) { msgs = unsupportedParts(msgs, model) msgs = normalizeMessages(msgs, model, options) - // kilocode_change start - apply caching for anthropic, alibaba, and GPT-5.6+ openai/azure/kilo-gateway if ( (model.providerID === "anthropic" || model.providerID === "google-vertex-anthropic" || @@ -490,18 +485,16 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re model.id.includes("claude") || model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/alibaba" || + // kilocode_change start ((model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure" || - model.api.npm === "@kilocode/kilo-gateway" || - model.providerID === "openai" || - model.providerID === "azure" || - model.providerID === "kilo") && + model.api.npm === "@kilocode/kilo-gateway") && supportsPromptCacheBreakpoint(model.api.id))) && + // kilocode_change end model.api.npm !== "@ai-sdk/gateway" ) { msgs = applyCaching(msgs, model) } - // kilocode_change end // Remap providerOptions keys from stored providerID to expected SDK key const key = sdkKey(model.api.npm) From 7c7c11e33400a4640ad7b6cb031921964c7fe70b Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:34:00 +0000 Subject: [PATCH 21/34] fix(cli): include modified alibaba line in kilocode_change block Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- packages/opencode/src/provider/transform.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index d366119a5f3..cf876f0c56c 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -484,8 +484,8 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re model.id.includes("anthropic") || model.id.includes("claude") || model.api.npm === "@ai-sdk/anthropic" || - model.api.npm === "@ai-sdk/alibaba" || // kilocode_change start + model.api.npm === "@ai-sdk/alibaba" || ((model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure" || model.api.npm === "@kilocode/kilo-gateway") && From d3548d179bf9814e16cd4acdeb91411bd046aaac Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:52:47 +0000 Subject: [PATCH 22/34] refactor(cli): remove part.synthetic and any-cast from applyCaching Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- packages/opencode/src/provider/transform.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index cf876f0c56c..d8b04db4662 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -388,18 +388,15 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage if (shouldUseContentOptions) { // kilocode_change start - place caching breakpoint on stable content before trailing - const parts = msg.content as any[] - const targetIndex = parts.findLastIndex( + const target = msg.content.findLast( (part) => - part && typeof part === "object" && + part !== null && part.type !== "tool-approval-request" && part.type !== "tool-approval-response" && - !(part.type === "text" && typeof part.text === "string" && part.text.startsWith("")) && - !part.synthetic, + !(part.type === "text" && part.text.startsWith("")), ) - const lastContent = targetIndex >= 0 ? parts[targetIndex] : parts[parts.length - 1] - // kilocode_change end + const lastContent = target ?? msg.content[msg.content.length - 1] if ( lastContent && typeof lastContent === "object" && @@ -409,6 +406,7 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage lastContent.providerOptions = mergeDeep(lastContent.providerOptions ?? {}, providerOptions) continue } + // kilocode_change end } msg.providerOptions = mergeDeep(msg.providerOptions ?? {}, providerOptions) @@ -476,6 +474,7 @@ function mapProviderOptions( export function message(msgs: ModelMessage[], model: Provider.Model, options: Record) { msgs = unsupportedParts(msgs, model) msgs = normalizeMessages(msgs, model, options) + // kilocode_change start - apply caching for anthropic, alibaba, and GPT-5.6+ openai/azure/kilo-gateway if ( (model.providerID === "anthropic" || model.providerID === "google-vertex-anthropic" || @@ -484,17 +483,16 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re model.id.includes("anthropic") || model.id.includes("claude") || model.api.npm === "@ai-sdk/anthropic" || - // kilocode_change start model.api.npm === "@ai-sdk/alibaba" || ((model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure" || model.api.npm === "@kilocode/kilo-gateway") && supportsPromptCacheBreakpoint(model.api.id))) && - // kilocode_change end model.api.npm !== "@ai-sdk/gateway" ) { msgs = applyCaching(msgs, model) } + // kilocode_change end // Remap providerOptions keys from stored providerID to expected SDK key const key = sdkKey(model.api.npm) From 71abd522b1fd8988046df726f08f9e804e8f45ff Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:06:53 +0000 Subject: [PATCH 23/34] fix(cli): use typed loop and narrow kilocode_change block in applyCaching Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- packages/opencode/src/provider/transform.ts | 25 +++++++++++++-------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index d8b04db4662..ff534bf107f 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -386,17 +386,24 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage model.api.npm === "@ai-sdk/amazon-bedrock" const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0 - if (shouldUseContentOptions) { - // kilocode_change start - place caching breakpoint on stable content before trailing - const target = msg.content.findLast( - (part) => + // kilocode_change start - place caching breakpoint on stable content before trailing + if (shouldUseContentOptions && Array.isArray(msg.content)) { + const parts = msg.content + let targetIndex = -1 + for (let i = parts.length - 1; i >= 0; i--) { + const part = parts[i] + if ( + part && typeof part === "object" && - part !== null && part.type !== "tool-approval-request" && part.type !== "tool-approval-response" && - !(part.type === "text" && part.text.startsWith("")), - ) - const lastContent = target ?? msg.content[msg.content.length - 1] + !(part.type === "text" && part.text.startsWith("")) + ) { + targetIndex = i + break + } + } + const lastContent = targetIndex >= 0 ? parts[targetIndex] : parts[parts.length - 1] if ( lastContent && typeof lastContent === "object" && @@ -406,8 +413,8 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage lastContent.providerOptions = mergeDeep(lastContent.providerOptions ?? {}, providerOptions) continue } - // kilocode_change end } + // kilocode_change end msg.providerOptions = mergeDeep(msg.providerOptions ?? {}, providerOptions) } From b1191437754b7a301b2e1fc15168df758022260c Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Sun, 9 Aug 2026 11:20:23 -0400 Subject: [PATCH 24/34] docs(kilo-docs): document local session search --- packages/kilo-docs/lib/nav/code-with-ai.ts | 4 + .../code-with-ai/agents/chat-interface.md | 4 +- .../code-with-ai/agents/context-mentions.md | 4 +- .../code-with-ai/agents/session-history.md | 285 ++++++++++++++++++ .../pages/collaborate/sessions-sharing.md | 6 +- .../troubleshooting-extension.md | 4 +- 6 files changed, 300 insertions(+), 7 deletions(-) create mode 100644 packages/kilo-docs/pages/code-with-ai/agents/session-history.md diff --git a/packages/kilo-docs/lib/nav/code-with-ai.ts b/packages/kilo-docs/lib/nav/code-with-ai.ts index c8a8c0a2238..0db4e63092f 100644 --- a/packages/kilo-docs/lib/nav/code-with-ai.ts +++ b/packages/kilo-docs/lib/nav/code-with-ai.ts @@ -64,6 +64,10 @@ export const CodeWithAiNav: NavSection[] = [ href: "/code-with-ai/agents/chat-interface", children: "Chat Interface", }, + { + href: "/code-with-ai/agents/session-history", + children: "Session History and Search", + }, { href: "/code-with-ai/agents/context-mentions", children: "Context & Mentions", diff --git a/packages/kilo-docs/pages/code-with-ai/agents/chat-interface.md b/packages/kilo-docs/pages/code-with-ai/agents/chat-interface.md index c700cb92803..eece30eb088 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/chat-interface.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/chat-interface.md @@ -63,7 +63,7 @@ Open your terminal and run `kilo` to launch the interactive terminal interface ( - **Input prompt** - Type your requests and press Enter to send - **Action buttons** - Approve or reject proposed changes, answer questions - **Agent dropdown** - Switch between agents (e.g. Code, Ask, Plan) from the sidebar -- **Session management** - Start new sessions or resume previous ones +- **Session management** - Start new sessions or [search and resume previous ones](/docs/code-with-ai/agents/session-history) **Providing context:** @@ -89,7 +89,7 @@ You can also rename local sessions from **History** using the edit button or the - **Input prompt** - Type your requests and press Enter to send - **Action buttons** - Approve or reject proposed changes, answer questions - **Agent cycling** - Switch between agents using keybinds or slash commands -- **Session management** - Start new sessions or resume previous ones +- **Session management** - Start new sessions or [search and resume previous ones](/docs/code-with-ai/agents/session-history) - **New task** - Start a new task, available using the `+` button at the top or `New Task` button above the chat input - **Worktree** - Continue the current task with it's git state and session history in the Agent Manager in an isolated worktree - **File changes** - Shows the number of lines changed and opens a diff view diff --git a/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md b/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md index 7978ddd868f..1e598065ebc 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md @@ -31,7 +31,7 @@ Selecting a suggestion inserts the mention and highlights it in the input. File ### Referencing Past Chats -Choosing **Past chats** from the `@` menu opens a searchable picker of your previous sessions in the current workspace, ordered by recency. Selecting a session inserts a highlighted mention token; when you send the message, that session's current transcript is attached as context so the agent can build on the earlier conversation. Clicking the mention token opens the referenced session. +Choosing **Past chats** from the `@` menu opens a searchable picker of sessions in the current repository's Git worktree family, ordered by recency. Search matches session titles and worktree names. Selecting a session inserts a highlighted mention token; when you send the message, that session's current transcript is attached as context so the agent can build on the earlier conversation. Clicking the mention token opens the referenced session. Very long transcripts are truncated, keeping the beginning and end, so a single mention cannot fill the context window. @@ -77,7 +77,7 @@ This means the agent can explore your entire project as needed, rather than bein | **Mention files when helpful** | If you know the exact file, mention its path to save the agent a search step | | **Keep editor tabs relevant** | Open tabs are passed as context, so keep relevant files open | | **Trust the agent's tools** | The agent can search, read, and explore your codebase — let it do the discovery work | -| **Reference a past chat** | Type `@` and choose **Past chats** to open a searchable picker of previous sessions in the current workspace. Selecting a session attaches its current transcript as context when you send the message. | +| **Reference a past chat** | Type `@` and choose **Past chats** to search sessions in the current repository's worktree family. Selecting a session attaches its current transcript as context when you send the message. | {% /tab %} {% tab label="CLI" %} diff --git a/packages/kilo-docs/pages/code-with-ai/agents/session-history.md b/packages/kilo-docs/pages/code-with-ai/agents/session-history.md new file mode 100644 index 00000000000..a9bf54e50e4 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/agents/session-history.md @@ -0,0 +1,285 @@ +--- +title: "Session History and Search" +description: "Find, search, inspect, and resume local Kilo Code sessions" +--- + +# Session history and search + +Kilo Code keeps local session metadata and transcripts so you can resume earlier work. You can search session titles in the history UI or CLI, ask the agent to search transcript content, or inspect the local SQLite database directly. + +{% callout type="info" %} +Local sessions use SQLite, not PostgreSQL. Use `kilo db` or `sqlite3`, not `psql`, to inspect the local database. +{% /callout %} + +## Choose a search method + +Different search surfaces cover different content and scopes: + +| Method | Searches | Scope | +|---|---|---| +| VS Code or JetBrains History | Session titles | Sessions loaded in the selected Local or Cloud history view | +| CLI `/sessions` picker | Session titles | Current project and its Git worktrees | +| `kilo session list --search` | Session titles | Current project, or every local project with `--all` | +| Ask Kilo to recall past chats | Titles and high-signal transcript content | Current project and its Git worktrees | +| VS Code transcript search | Rendered content in the open session | Current session | +| SQLite query | Any stored field you select | Entire selected local database | + +History and CLI list searches do **not** search message content. To find a string inside prompts or replies, ask Kilo to search past chats or use a direct SQLite query. + +## Search in the UI + +{% tabs %} +{% tab label="VS Code" %} + +### Search session history + +1. Open the Kilo Code sidebar. +2. Select the **History** button in the sidebar title bar. You can also select **Show History** below the recent sessions on a new chat. +3. Select **Local** or **Cloud**. +4. Enter part of a session title in **Search sessions**. +5. Select a result to reopen it. + +Local history actions also let you rename, export, or delete a session. Cloud history can be filtered to **Only this repository**. + +The History search is a fuzzy title search over the sessions currently loaded into the view. It does not search prompts or agent replies. + +### Search the open transcript + +Open a session and select the magnifying-glass button in its header. Transcript search supports: + +- Previous and next match navigation +- Match case +- Whole word +- Regular expressions + +Kilo loads older messages while the search is active so the search covers the full local transcript, not only the messages initially visible. + +### Reference another session + +Type `@` in the chat input, select **Past chats**, and search by session title or worktree name. Selecting a result attaches that transcript as context when you send the message. Past chats includes sessions in the current repository's Git worktree family. + +{% /tab %} +{% tab label="JetBrains" %} + +1. Open the Kilo Code tool window. +2. Select **History** in the tool window toolbar. +3. Select **Local** or **Cloud**. +4. Enter part of a session title in **Search sessions**. +5. Select a result to reopen it. + +You can rename or delete local sessions. In Cloud history, enable **Only this repository** to narrow the list. + +The JetBrains History search matches session titles, not transcript content. + +{% /tab %} +{% tab label="CLI TUI" %} + +Run `kilo`, then enter any of these commands: + +```text +/sessions +/resume +/continue +``` + +Start typing in the session picker to filter sessions by title. The picker also supports reopening, pinning, and deleting local sessions. + +{% /tab %} +{% /tabs %} + +## Ask Kilo to search transcripts + +The simplest way to search message content across past local sessions is to ask Kilo directly: + +```text +Search my local sessions for "database disk image is malformed" and summarize the matching conversations. +``` + +Kilo's local recall search covers session titles, user and assistant text, file references, and failed tool errors in the current project and its Git worktrees. Every query term must occur somewhere in a matching session; exact phrases and user-authored matches rank higher. + +The search includes archived and child sessions. It excludes reasoning, synthetic or ignored text, successful tool output, file contents, and other metadata. Kilo can then read the full transcript for a selected result. + +## Search with CLI commands + +### Filter session titles + +List sessions in the current project: + +```bash +kilo session list --search "database migration" +``` + +Search titles across every project in the local database and return JSON: + +```bash +kilo session list --all --search "database migration" --format json +``` + +Limit the number of results: + +```bash +kilo session list --all --search "migration" --max-count 25 +``` + +`--search` performs a SQLite `LIKE` substring search on the title. It does not inspect messages. Use the returned session ID to resume or export a session: + +```bash +kilo --session ses_123 +kilo run --session ses_123 "Summarize where we stopped" +kilo export ses_123 > session.json +``` + +### Find the local database + +Print the database selected by the current CLI environment: + +```bash +kilo db path +``` + +Stable installations normally use these paths: + +| Environment | Default path | +|---|---| +| Windows | `%LOCALAPPDATA%\kilo\kilo.db` | +| macOS | `~/Library/Application Support/kilo/kilo.db` | +| Linux | `~/.local/share/kilo/kilo.db` | +| VS Code Remote SSH | `~/.local/share/kilo/kilo.db` on the remote machine | + +The actual path can differ when `KILO_DB`, `XDG_DATA_HOME`, a development channel, or an isolated development environment is active. Prefer `kilo db path` over assuming a path. + +## Query SQLite directly + +{% callout type="warning" %} +The `kilo db` query command accepts arbitrary SQL, including writes. Use read-only queries, and use `sqlite3 -readonly` for safer interactive inspection. Stop Kilo before copying, replacing, or restoring the database because active transactions may also use `kilo.db-wal` and `kilo.db-shm`. +{% /callout %} + +### Use the built-in database command + +List recently updated sessions: + +```bash +kilo db " + SELECT + id, + title, + directory, + datetime(time_updated / 1000, 'unixepoch') AS updated + FROM session + ORDER BY time_updated DESC + LIMIT 25 +" --format json +``` + +Search non-synthetic text parts for a case-insensitive substring: + +```bash +kilo db " + SELECT + s.id AS session_id, + s.title, + json_extract(m.data, '$.role') AS role, + json_extract(p.data, '$.text') AS text + FROM session AS s + JOIN message AS m ON m.session_id = s.id + JOIN part AS p ON p.message_id = m.id + WHERE json_valid(p.data) + AND json_extract(p.data, '$.type') = 'text' + AND coalesce(json_extract(p.data, '$.synthetic'), 0) = 0 + AND coalesce(json_extract(p.data, '$.ignored'), 0) = 0 + AND instr( + lower(json_extract(p.data, '$.text')), + lower('database disk image is malformed') + ) > 0 + ORDER BY s.time_updated DESC, m.time_created, p.id +" --format json +``` + +Replace the search string in the second `lower(...)` expression. This query searches ordinary user and assistant text. It intentionally does not reproduce Kilo's recall ranking, worktree-family scope, file-reference matching, or tool-error matching. + +### Use the SQLite shell + +Install the `sqlite3` command-line tool, then open the selected database in read-only mode: + +```bash +sqlite3 -readonly "$(kilo db path)" +``` + +Useful commands inside the shell include: + +```sql +.tables +.schema session +.schema message +.schema part +``` + +Run the SQL examples above without the surrounding `kilo db` command. Exit with `.quit`. + +`psql` cannot open this file because `psql` speaks the PostgreSQL client protocol, while local Kilo sessions are stored in an embedded SQLite database. + +## Search through the API or SDK + +Run a local server with authentication before exposing its API: + +```bash +export KILO_SERVER_PASSWORD='replace-with-a-strong-password' +kilo serve --port 4096 +``` + +Search session titles in the current directory: + +```bash +curl -u "kilo:$KILO_SERVER_PASSWORD" \ + --get "http://127.0.0.1:4096/session" \ + --data-urlencode "directory=$PWD" \ + --data-urlencode "roots=true" \ + --data-urlencode "search=migration" \ + --data-urlencode "limit=50" +``` + +Read the complete transcript for one result: + +```bash +curl -u "kilo:$KILO_SERVER_PASSWORD" \ + --get "http://127.0.0.1:4096/session/ses_123/message" \ + --data-urlencode "directory=$PWD" +``` + +The list endpoint's `search` parameter searches titles only. To search transcript text programmatically, list candidate sessions, read their messages, and inspect text parts in your application. + +The JavaScript SDK exposes the same endpoints: + +```ts +import { createKiloClient } from "@kilocode/sdk/v2/client" + +const auth = Buffer.from(`kilo:${process.env.KILO_SERVER_PASSWORD}`).toString("base64") +const client = createKiloClient({ + baseUrl: "http://127.0.0.1:4096", + directory: process.cwd(), + headers: { Authorization: `Basic ${auth}` }, +}) + +const result = await client.session.list( + { roots: true, search: "migration", limit: 50 }, + { throwOnError: true }, +) + +for (const session of result.data) { + const transcript = await client.session.messages( + { sessionID: session.id, limit: 0 }, + { throwOnError: true }, + ) + + // Inspect transcript.data[*].parts for the content your application needs. + console.log(session.title, transcript.data.length) +} +``` + +`limit: 0` requests the complete transcript from the compatibility API. For large sessions, use a positive `limit` and follow the pagination cursor instead. + +## Local and cloud sessions + +Local sessions live in the SQLite database on the machine where Kilo runs. With VS Code Remote SSH, that means the remote host. Cloud sessions and shared-session copies are separate and are not available through local SQLite queries unless you import them locally. + +For database corruption or reset procedures, see [Troubleshooting IDE Extensions](/docs/getting-started/troubleshooting/troubleshooting-extension). Resetting the database removes local session history, so back it up before recovery work. diff --git a/packages/kilo-docs/pages/collaborate/sessions-sharing.md b/packages/kilo-docs/pages/collaborate/sessions-sharing.md index a43be9f0074..86c62f407aa 100644 --- a/packages/kilo-docs/pages/collaborate/sessions-sharing.md +++ b/packages/kilo-docs/pages/collaborate/sessions-sharing.md @@ -5,7 +5,7 @@ description: "Share and collaborate on Kilo Code sessions" # Sessions & Sharing -A session is your platform-agnostic interaction with Kilo. It remembers your repository, your task, and the conversation so you can pause and resume work without losing context. Sessions are private to your account by default; you can optionally share a link with others who can read or fork your session. +A session is your platform-agnostic interaction with Kilo. It remembers your repository, your task, and the conversation so you can pause and resume work without losing context. Local sessions stay on the machine where Kilo runs. Account-backed cloud sessions are private to your account by default. You can optionally share a session link with others who can read or fork it. ## What a session keeps for you @@ -49,7 +49,9 @@ Forking creates a new session in your account, with its own ID, and copies over ## Where your session data lives -To keep sessions fast and resumable, Kilo stores small JSON blobs associated with your session. These include your conversation history and task metadata. If you share a session, Kilo keeps a public copy used by the share link while your private session remains under your account. +Kilo stores local session history and metadata in an SQLite database on the machine where Kilo runs. Cloud sessions and shared-session copies use Kilo's cloud services and are separate from the local database. If you share a session, Kilo keeps a public copy for the share link while the original session remains private. + +See [Session History and Search](/docs/code-with-ai/agents/session-history) to find the local database, search session titles or transcripts, and inspect sessions with the CLI or SQLite. Good practice: diff --git a/packages/kilo-docs/pages/getting-started/troubleshooting/troubleshooting-extension.md b/packages/kilo-docs/pages/getting-started/troubleshooting/troubleshooting-extension.md index 23f6a8ae8f4..368d5ac134d 100644 --- a/packages/kilo-docs/pages/getting-started/troubleshooting/troubleshooting-extension.md +++ b/packages/kilo-docs/pages/getting-started/troubleshooting/troubleshooting-extension.md @@ -27,7 +27,9 @@ If every prompt fails with `SQLiteError: database disk image is malformed`, Kilo ### Find the database -The database location depends on where Kilo Code is running: +When the kilo CLI uses the same environment as the affected installation, run `kilo db path` to print the selected database. See [Session History and Search](/docs/code-with-ai/agents/session-history) for normal database inspection and search workflows. + +The default database location depends on where Kilo Code is running: | Environment | Database path | |---|---| From 5fb5d4d5c012e5bccb7d2d4c388375154975d4a2 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Sun, 9 Aug 2026 11:29:15 -0400 Subject: [PATCH 25/34] docs(kilo-docs): standardize session scope terminology --- .../code-with-ai/agents/context-mentions.md | 4 ++-- .../pages/code-with-ai/agents/session-history.md | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md b/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md index 1e598065ebc..4aa682e9811 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md @@ -31,7 +31,7 @@ Selecting a suggestion inserts the mention and highlights it in the input. File ### Referencing Past Chats -Choosing **Past chats** from the `@` menu opens a searchable picker of sessions in the current repository's Git worktree family, ordered by recency. Search matches session titles and worktree names. Selecting a session inserts a highlighted mention token; when you send the message, that session's current transcript is attached as context so the agent can build on the earlier conversation. Clicking the mention token opens the referenced session. +Choosing **Past chats** from the `@` menu opens a searchable picker of sessions in the current workspace, ordered by recency. Search matches session titles and workspace names. Selecting a session inserts a highlighted mention token; when you send the message, that session's current transcript is attached as context so the agent can build on the earlier conversation. Clicking the mention token opens the referenced session. Very long transcripts are truncated, keeping the beginning and end, so a single mention cannot fill the context window. @@ -77,7 +77,7 @@ This means the agent can explore your entire project as needed, rather than bein | **Mention files when helpful** | If you know the exact file, mention its path to save the agent a search step | | **Keep editor tabs relevant** | Open tabs are passed as context, so keep relevant files open | | **Trust the agent's tools** | The agent can search, read, and explore your codebase — let it do the discovery work | -| **Reference a past chat** | Type `@` and choose **Past chats** to search sessions in the current repository's worktree family. Selecting a session attaches its current transcript as context when you send the message. | +| **Reference a past chat** | Type `@` and choose **Past chats** to search sessions in the current workspace. Selecting a session attaches its current transcript as context when you send the message. | {% /tab %} {% tab label="CLI" %} diff --git a/packages/kilo-docs/pages/code-with-ai/agents/session-history.md b/packages/kilo-docs/pages/code-with-ai/agents/session-history.md index a9bf54e50e4..4488b301b71 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/session-history.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/session-history.md @@ -18,9 +18,9 @@ Different search surfaces cover different content and scopes: | Method | Searches | Scope | |---|---|---| | VS Code or JetBrains History | Session titles | Sessions loaded in the selected Local or Cloud history view | -| CLI `/sessions` picker | Session titles | Current project and its Git worktrees | -| `kilo session list --search` | Session titles | Current project, or every local project with `--all` | -| Ask Kilo to recall past chats | Titles and high-signal transcript content | Current project and its Git worktrees | +| CLI `/sessions` picker | Session titles | Current workspace | +| `kilo session list --search` | Session titles | Current workspace, or every local workspace with `--all` | +| Ask Kilo to recall past chats | Titles and high-signal transcript content | Current workspace | | VS Code transcript search | Rendered content in the open session | Current session | | SQLite query | Any stored field you select | Entire selected local database | @@ -56,7 +56,7 @@ Kilo loads older messages while the search is active so the search covers the fu ### Reference another session -Type `@` in the chat input, select **Past chats**, and search by session title or worktree name. Selecting a result attaches that transcript as context when you send the message. Past chats includes sessions in the current repository's Git worktree family. +Type `@` in the chat input, select **Past chats**, and search by session title or workspace name. Selecting a result attaches that transcript as context when you send the message. Past chats includes sessions in the current workspace. {% /tab %} {% tab label="JetBrains" %} @@ -95,7 +95,7 @@ The simplest way to search message content across past local sessions is to ask Search my local sessions for "database disk image is malformed" and summarize the matching conversations. ``` -Kilo's local recall search covers session titles, user and assistant text, file references, and failed tool errors in the current project and its Git worktrees. Every query term must occur somewhere in a matching session; exact phrases and user-authored matches rank higher. +Kilo's local recall search covers session titles, user and assistant text, file references, and failed tool errors in the current workspace. Every query term must occur somewhere in a matching session; exact phrases and user-authored matches rank higher. The search includes archived and child sessions. It excludes reasoning, synthetic or ignored text, successful tool output, file contents, and other metadata. Kilo can then read the full transcript for a selected result. @@ -103,13 +103,13 @@ The search includes archived and child sessions. It excludes reasoning, syntheti ### Filter session titles -List sessions in the current project: +List sessions in the current workspace: ```bash kilo session list --search "database migration" ``` -Search titles across every project in the local database and return JSON: +Search titles across every local workspace and return JSON: ```bash kilo session list --all --search "database migration" --format json @@ -195,7 +195,7 @@ kilo db " " --format json ``` -Replace the search string in the second `lower(...)` expression. This query searches ordinary user and assistant text. It intentionally does not reproduce Kilo's recall ranking, worktree-family scope, file-reference matching, or tool-error matching. +Replace the search string in the second `lower(...)` expression. This query searches ordinary user and assistant text. It intentionally does not reproduce Kilo's recall ranking, workspace scope, file-reference matching, or tool-error matching. ### Use the SQLite shell From c237a69d01ef8ef3a03ec82dcd1e026e3d055697 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Sun, 9 Aug 2026 11:34:23 -0400 Subject: [PATCH 26/34] docs(kilo-docs): address session search review --- .../code-with-ai/agents/context-mentions.md | 8 +-- .../code-with-ai/agents/session-history.md | 50 +++++++++++-------- 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md b/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md index 4aa682e9811..6cfe92716a2 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md @@ -25,15 +25,15 @@ Type `@` in the chat input to get autocomplete suggestions. You can mention: | **File** | Attach a file's contents to your message | `@src/utils.ts` | | **Terminal** | Include your active VS Code terminal output | `@terminal` | | **Git Changes** | Attach uncommitted working-tree diffs and new files | `@git-changes` | -| **Past chats** | Attach a previous session's transcript as context | `@` → **Past chats** → pick a session | +| **Past chats** | Add a previous session's chat history as context | `@` → **Past chats** → pick a session | Selecting a suggestion inserts the mention and highlights it in the input. File contents, terminal output, and git changes are attached as context when you send the message. ### Referencing Past Chats -Choosing **Past chats** from the `@` menu opens a searchable picker of sessions in the current workspace, ordered by recency. Search matches session titles and workspace names. Selecting a session inserts a highlighted mention token; when you send the message, that session's current transcript is attached as context so the agent can build on the earlier conversation. Clicking the mention token opens the referenced session. +Choosing **Past chats** from the `@` menu opens a searchable picker of sessions in the current workspace, ordered by recency. Search matches session titles and workspace names. Selecting a session inserts a highlighted mention token; when you send the message, that session's current chat history is added as context so the agent can build on the earlier conversation. Clicking the mention token opens the referenced session. -Very long transcripts are truncated, keeping the beginning and end, so a single mention cannot fill the context window. +Very long chat histories are truncated, keeping the beginning and end, so a single mention cannot fill the context window. ### Drag and Drop @@ -77,7 +77,7 @@ This means the agent can explore your entire project as needed, rather than bein | **Mention files when helpful** | If you know the exact file, mention its path to save the agent a search step | | **Keep editor tabs relevant** | Open tabs are passed as context, so keep relevant files open | | **Trust the agent's tools** | The agent can search, read, and explore your codebase — let it do the discovery work | -| **Reference a past chat** | Type `@` and choose **Past chats** to search sessions in the current workspace. Selecting a session attaches its current transcript as context when you send the message. | +| **Reference a past chat** | Type `@` and choose **Past chats** to search sessions in the current workspace. Selecting a session adds its current chat history as context when you send the message. | {% /tab %} {% tab label="CLI" %} diff --git a/packages/kilo-docs/pages/code-with-ai/agents/session-history.md b/packages/kilo-docs/pages/code-with-ai/agents/session-history.md index 4488b301b71..8ae1641ceee 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/session-history.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/session-history.md @@ -5,7 +5,7 @@ description: "Find, search, inspect, and resume local Kilo Code sessions" # Session history and search -Kilo Code keeps local session metadata and transcripts so you can resume earlier work. You can search session titles in the history UI or CLI, ask the agent to search transcript content, or inspect the local SQLite database directly. +Kilo Code keeps local session metadata and chat history so you can resume earlier work. You can search session titles in the history UI or CLI, ask the agent to search chat content, or inspect the local SQLite database directly. {% callout type="info" %} Local sessions use SQLite, not PostgreSQL. Use `kilo db` or `sqlite3`, not `psql`, to inspect the local database. @@ -17,12 +17,14 @@ Different search surfaces cover different content and scopes: | Method | Searches | Scope | |---|---|---| -| VS Code or JetBrains History | Session titles | Sessions loaded in the selected Local or Cloud history view | -| CLI `/sessions` picker | Session titles | Current workspace | -| `kilo session list --search` | Session titles | Current workspace, or every local workspace with `--all` | -| Ask Kilo to recall past chats | Titles and high-signal transcript content | Current workspace | -| VS Code transcript search | Rendered content in the open session | Current session | -| SQLite query | Any stored field you select | Entire selected local database | +| [VS Code History](#search-session-history-in-vs-code) | Session titles | Sessions loaded in the selected Local or Cloud history view | +| [JetBrains History](#search-session-history-in-jetbrains) | Session titles | Sessions loaded in the selected Local or Cloud history view | +| [CLI `/sessions` picker](#search-session-history-in-the-cli-tui) | Session titles | Current workspace | +| [`kilo session list --search`](#filter-session-titles) | Session titles | Current workspace, or every local workspace with `--all` | +| [Ask Kilo to recall past chats](#ask-kilo-to-search-past-chats) | Titles and high-signal chat content | Current workspace | +| [VS Code chat search](#search-the-open-chat-session) | Rendered content in the open session | Current chat session | +| [VS Code Past chats](#add-a-past-chat-to-the-current-session) | Session titles and workspace names | Current workspace | +| [SQLite query](#query-sqlite-directly) | Any stored field you select | Entire selected local database | History and CLI list searches do **not** search message content. To find a string inside prompts or replies, ask Kilo to search past chats or use a direct SQLite query. @@ -31,7 +33,7 @@ History and CLI list searches do **not** search message content. To find a strin {% tabs %} {% tab label="VS Code" %} -### Search session history +### Search session history in VS Code 1. Open the Kilo Code sidebar. 2. Select the **History** button in the sidebar title bar. You can also select **Show History** below the recent sessions on a new chat. @@ -43,24 +45,26 @@ Local history actions also let you rename, export, or delete a session. Cloud hi The History search is a fuzzy title search over the sessions currently loaded into the view. It does not search prompts or agent replies. -### Search the open transcript +### Search the open chat session -Open a session and select the magnifying-glass button in its header. Transcript search supports: +Open a session and select the magnifying-glass button in its header. Chat search supports: - Previous and next match navigation - Match case - Whole word - Regular expressions -Kilo loads older messages while the search is active so the search covers the full local transcript, not only the messages initially visible. +Kilo loads older messages while the search is active so the search covers the full local chat session, not only the messages initially visible. -### Reference another session +### Add a past chat to the current session -Type `@` in the chat input, select **Past chats**, and search by session title or workspace name. Selecting a result attaches that transcript as context when you send the message. Past chats includes sessions in the current workspace. +Type `@` in the chat input, select **Past chats**, and search by session title or workspace name. Selecting a result adds that past chat's content as context when you send the message from your current session. It does not switch or reopen sessions. Past chats includes sessions in the current workspace. {% /tab %} {% tab label="JetBrains" %} +### Search session history in JetBrains + 1. Open the Kilo Code tool window. 2. Select **History** in the tool window toolbar. 3. Select **Local** or **Cloud**. @@ -69,11 +73,13 @@ Type `@` in the chat input, select **Past chats**, and search by session title o You can rename or delete local sessions. In Cloud history, enable **Only this repository** to narrow the list. -The JetBrains History search matches session titles, not transcript content. +The JetBrains History search matches session titles, not chat content. {% /tab %} {% tab label="CLI TUI" %} +### Search session history in the CLI TUI + Run `kilo`, then enter any of these commands: ```text @@ -87,7 +93,7 @@ Start typing in the session picker to filter sessions by title. The picker also {% /tab %} {% /tabs %} -## Ask Kilo to search transcripts +## Ask Kilo to search past chats The simplest way to search message content across past local sessions is to ask Kilo directly: @@ -97,7 +103,7 @@ Search my local sessions for "database disk image is malformed" and summarize th Kilo's local recall search covers session titles, user and assistant text, file references, and failed tool errors in the current workspace. Every query term must occur somewhere in a matching session; exact phrases and user-authored matches rank higher. -The search includes archived and child sessions. It excludes reasoning, synthetic or ignored text, successful tool output, file contents, and other metadata. Kilo can then read the full transcript for a selected result. +The search includes archived and child sessions. It excludes reasoning, synthetic or ignored text, successful tool output, file contents, and other metadata. Kilo can then read the full chat history for a selected result. ## Search with CLI commands @@ -238,7 +244,7 @@ curl -u "kilo:$KILO_SERVER_PASSWORD" \ --data-urlencode "limit=50" ``` -Read the complete transcript for one result: +Read the complete chat history for one result: ```bash curl -u "kilo:$KILO_SERVER_PASSWORD" \ @@ -246,7 +252,7 @@ curl -u "kilo:$KILO_SERVER_PASSWORD" \ --data-urlencode "directory=$PWD" ``` -The list endpoint's `search` parameter searches titles only. To search transcript text programmatically, list candidate sessions, read their messages, and inspect text parts in your application. +The list endpoint's `search` parameter searches titles only. To search chat content programmatically, list candidate sessions, read their messages, and inspect text parts in your application. The JavaScript SDK exposes the same endpoints: @@ -266,17 +272,17 @@ const result = await client.session.list( ) for (const session of result.data) { - const transcript = await client.session.messages( + const history = await client.session.messages( { sessionID: session.id, limit: 0 }, { throwOnError: true }, ) - // Inspect transcript.data[*].parts for the content your application needs. - console.log(session.title, transcript.data.length) + // Inspect history.data[*].parts for the content your application needs. + console.log(session.title, history.data.length) } ``` -`limit: 0` requests the complete transcript from the compatibility API. For large sessions, use a positive `limit` and follow the pagination cursor instead. +`limit: 0` requests the complete chat history from the compatibility API. For large sessions, use a positive `limit` and follow the pagination cursor instead. ## Local and cloud sessions From 0da7d5096548c4e5eefc7d2b7f41fd755590d512 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Sun, 9 Aug 2026 11:37:46 -0400 Subject: [PATCH 27/34] docs(kilo-docs): clarify CLI session loading --- .../kilo-docs/pages/code-with-ai/agents/session-history.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/session-history.md b/packages/kilo-docs/pages/code-with-ai/agents/session-history.md index 8ae1641ceee..a368bf4cbc8 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/session-history.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/session-history.md @@ -19,7 +19,7 @@ Different search surfaces cover different content and scopes: |---|---|---| | [VS Code History](#search-session-history-in-vs-code) | Session titles | Sessions loaded in the selected Local or Cloud history view | | [JetBrains History](#search-session-history-in-jetbrains) | Session titles | Sessions loaded in the selected Local or Cloud history view | -| [CLI `/sessions` picker](#search-session-history-in-the-cli-tui) | Session titles | Current workspace | +| [CLI `/sessions` picker](#load-a-different-session-in-the-cli-tui) | Session titles | Current workspace | | [`kilo session list --search`](#filter-session-titles) | Session titles | Current workspace, or every local workspace with `--all` | | [Ask Kilo to recall past chats](#ask-kilo-to-search-past-chats) | Titles and high-signal chat content | Current workspace | | [VS Code chat search](#search-the-open-chat-session) | Rendered content in the open session | Current chat session | @@ -78,7 +78,7 @@ The JetBrains History search matches session titles, not chat content. {% /tab %} {% tab label="CLI TUI" %} -### Search session history in the CLI TUI +### Load a different session in the CLI TUI Run `kilo`, then enter any of these commands: @@ -88,7 +88,7 @@ Run `kilo`, then enter any of these commands: /continue ``` -Start typing in the session picker to filter sessions by title. The picker also supports reopening, pinning, and deleting local sessions. +Start typing in the session picker to filter sessions by title, then select a result to load that chat session. This switches the TUI to the selected session; it does not add the session as context to your current chat. The picker also supports pinning and deleting local sessions. {% /tab %} {% /tabs %} From 4ff8f5e1105652b98f49210608e9f28e98a92f8b Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:44:33 +0000 Subject: [PATCH 28/34] feat(vscode): show Auto model routes for every kilo-auto model Show the underlying model list in the selector whenever an Auto model includes autoRouting, not only kilo-auto/efficient. Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .changeset/auto-model-route-choices.md | 5 ++++ .../model-selector-accessibility.spec.ts | 14 ++++++++++ .../tests/unit/model-selector-utils.test.ts | 26 ++++++++++++++++--- .../components/shared/model-selector-utils.ts | 7 +---- .../webview-ui/src/stories/shared.stories.tsx | 13 +++++++++- 5 files changed, 55 insertions(+), 10 deletions(-) create mode 100644 .changeset/auto-model-route-choices.md diff --git a/.changeset/auto-model-route-choices.md b/.changeset/auto-model-route-choices.md new file mode 100644 index 00000000000..6edfcabe146 --- /dev/null +++ b/.changeset/auto-model-route-choices.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show underlying models for every Auto model in the model selector when the catalog includes them. diff --git a/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts b/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts index 785bf9faf2f..de54f3b5e00 100644 --- a/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts +++ b/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts @@ -80,6 +80,20 @@ test("auto efficient details show server description and model choices", async ( await expect(preview).not.toContainText("openai/gpt-5.5") }) +test("auto frontier details show model choices when routes are present", async ({ page }) => { + await load(page, "shared--model-selector-accessible") + + await page.getByRole("button", { name: "Review model: Alpha" }).click() + await page.getByRole("treeitem", { name: /Kilo Auto Frontier/ }).click() + + const preview = page.locator(".model-selector-preview") + await expect(preview).toContainText("Routes each request to the strongest available models.") + await expect(preview).toContainText("Model choices") + await expect(preview).toContainText("openai/gpt-5.5") + await expect(preview).toContainText("anthropic/claude-opus-4.6") + await expect(preview).not.toContainText("google/gemini-2.5-flash") +}) + test("search uses a flat relevance-ranked result list with provider labels", async ({ page }) => { await load(page, "shared--model-selector-accessible") diff --git a/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts b/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts index d1c681f89fc..59956de5ef1 100644 --- a/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts @@ -128,7 +128,7 @@ describe("isAuto", () => { }) describe("autoChoices", () => { - it("uses backend Auto Efficient routes and resolves names when available", () => { + it("uses backend Auto routes and resolves names when available", () => { expect( autoChoices( { @@ -144,12 +144,32 @@ describe("autoChoices", () => { ]) }) - it("ignores missing routes and non-efficient Auto models", () => { + it("shows routes for any Auto model when present", () => { + expect( + autoChoices( + { + providerID: KILO_GATEWAY_ID, + id: "kilo-auto/frontier", + autoRouting: { models: ["provider/model"] }, + }, + [{ id: "provider/model", name: "Provider: Model" }], + ), + ).toEqual([{ id: "provider/model", name: "Model" }]) + expect( + autoChoices({ + providerID: KILO_GATEWAY_ID, + id: "kilo-auto/free", + autoRouting: { models: ["provider/model"] }, + }), + ).toEqual([{ id: "provider/model", name: "provider/model" }]) + }) + + it("ignores missing routes and non-Auto models", () => { expect(autoChoices({ providerID: KILO_GATEWAY_ID, id: "kilo-auto/efficient" })).toEqual([]) expect( autoChoices({ providerID: KILO_GATEWAY_ID, - id: "kilo-auto/frontier", + id: "anthropic/claude-sonnet", autoRouting: { models: ["provider/model"] }, }), ).toEqual([]) diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/model-selector-utils.ts b/packages/kilo-vscode/webview-ui/src/components/shared/model-selector-utils.ts index d93d142e37b..80d70c9cf8c 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/model-selector-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/shared/model-selector-utils.ts @@ -10,7 +10,6 @@ import { export { KILO_GATEWAY_ID, PROVIDER_ORDER } export const KILO_AUTO_SMALL_IDS = new Set(["kilo-auto/small", "auto-small"]) -export const KILO_AUTO_EFFICIENT_ID = "kilo-auto/efficient" const AUTO_FALLBACK = "Routes requests automatically." interface Choice { @@ -24,15 +23,11 @@ export function isAuto(model: Pick): boolean ) } -export function isAutoEfficient(model: Pick): boolean { - return model.providerID === KILO_GATEWAY_ID && model.id === KILO_AUTO_EFFICIENT_ID -} - export function autoChoices( model: Pick, catalog: readonly Pick[] = [], ): readonly Choice[] { - if (!isAutoEfficient(model)) return [] + if (!isAuto(model)) return [] const ids = model.autoRouting?.models if (!ids?.length) return [] const names = new Map(catalog.map((item) => [item.id, stripSubProviderPrefix(sanitizeName(item.name))])) diff --git a/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx index 48d6bd94a8a..8753cb96ae1 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx @@ -72,7 +72,18 @@ const ACCESSIBLE_MODELS: EnrichedModel[] = [ }, autoRouting: { models: ["google/gemini-2.5-flash", "anthropic/claude-sonnet-4.6"] }, }, - { id: "omega", name: "Omega", providerID: "openai", providerName: "OpenAI", recommendedIndex: 1 }, + { + id: "kilo-auto/frontier", + name: "Kilo Auto Frontier", + providerID: "kilo", + providerName: "Kilo", + recommendedIndex: 1, + options: { + description: "Routes each request to the strongest available models.", + }, + autoRouting: { models: ["openai/gpt-5.5", "anthropic/claude-opus-4.6"] }, + }, + { id: "omega", name: "Omega", providerID: "openai", providerName: "OpenAI", recommendedIndex: 2 }, { id: "alpha", name: "Alpha", providerID: "kilo", providerName: "Kilo" }, { id: "bravo", name: "Bravo", providerID: "kilo", providerName: "Kilo" }, { id: "charlie", name: "Charlie", providerID: "kilo", providerName: "Kilo" }, From a1776a1ea5a1b22fb12b9127bb71734e017c9d61 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Sun, 9 Aug 2026 11:44:34 -0400 Subject: [PATCH 29/34] docs(kilo-docs): document CLI past chat references --- .../code-with-ai/agents/context-mentions.md | 2 ++ .../code-with-ai/agents/session-history.md | 17 +++++++---------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md b/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md index 6cfe92716a2..0e433cca870 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md @@ -93,6 +93,7 @@ When you describe a task, the agent uses its tools — `read`, `grep`, `glob`, a In the terminal-based TUI, you can provide context in several ways: - **Type `@` for file autocomplete** — In the TUI, type `@` followed by a filename to get autocomplete suggestions. Selecting a file attaches its contents to your message. You can limit how much is included by appending a line range, e.g. `@src/utils.ts#10-50`. +- **Reference a past chat** — Type `@`, choose **Past chats**, then search for and select a session. Kilo adds that chat history as context when you send the message without switching sessions. - **Mention file paths in your message** — Simply refer to files by path in your conversation text (e.g., "look at src/utils.ts") and the agent will read them. - **Use `kilo run -f`** — When using the non-interactive `kilo run` command, pass `-f path/to/file.ts` to explicitly include a file's contents in the context. - **Let the agent find files itself** — The agent has access to `glob` (find files by pattern), `grep` (search file contents), and `read` (read file contents) tools. Describe what you're looking for and it will locate the relevant code. @@ -116,6 +117,7 @@ This means the agent can explore your entire project as needed, rather than bein |---|---| | **Describe the task clearly** | The agent finds context on its own — focus on _what_ you want done rather than _where_ the code is | | **Mention files when helpful** | If you know the exact file, mention its path to save the agent a search step | +| **Reference a past chat** | Type `@` and choose **Past chats** to add another session's chat history as context without switching sessions. | | **Use `kilo run -f`** | Pass key files with `-f` when using `kilo run` for immediate context | | **Trust the agent's tools** | The agent can search, read, and explore your codebase — let it do the discovery work | diff --git a/packages/kilo-docs/pages/code-with-ai/agents/session-history.md b/packages/kilo-docs/pages/code-with-ai/agents/session-history.md index a368bf4cbc8..4b78b52ae28 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/session-history.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/session-history.md @@ -19,7 +19,7 @@ Different search surfaces cover different content and scopes: |---|---|---| | [VS Code History](#search-session-history-in-vs-code) | Session titles | Sessions loaded in the selected Local or Cloud history view | | [JetBrains History](#search-session-history-in-jetbrains) | Session titles | Sessions loaded in the selected Local or Cloud history view | -| [CLI `/sessions` picker](#load-a-different-session-in-the-cli-tui) | Session titles | Current workspace | +| [CLI Past chats](#reference-a-past-chat-in-the-cli-tui) | Session titles | Current workspace | | [`kilo session list --search`](#filter-session-titles) | Session titles | Current workspace, or every local workspace with `--all` | | [Ask Kilo to recall past chats](#ask-kilo-to-search-past-chats) | Titles and high-signal chat content | Current workspace | | [VS Code chat search](#search-the-open-chat-session) | Rendered content in the open session | Current chat session | @@ -78,17 +78,14 @@ The JetBrains History search matches session titles, not chat content. {% /tab %} {% tab label="CLI TUI" %} -### Load a different session in the CLI TUI +### Reference a past chat in the CLI TUI -Run `kilo`, then enter any of these commands: +1. Run `kilo`. +2. Type `@` in the prompt and select **Past chats**. +3. Search for a session by title. +4. Select a session, finish your message, and send it. -```text -/sessions -/resume -/continue -``` - -Start typing in the session picker to filter sessions by title, then select a result to load that chat session. This switches the TUI to the selected session; it does not add the session as context to your current chat. The picker also supports pinning and deleting local sessions. +Kilo adds the selected chat history as context for your message. This does not switch the TUI to the selected session. Use `/sessions`, `/resume`, or `/continue` when you want to load a different session instead. {% /tab %} {% /tabs %} From 33a3eab7510f111cf12d16607d7b0bde9876318b Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Sun, 9 Aug 2026 14:29:39 -0400 Subject: [PATCH 30/34] docs(kilo-docs): correct session paths and scope --- .../pages/code-with-ai/agents/context-mentions.md | 4 ++-- .../pages/code-with-ai/agents/session-history.md | 12 ++++++------ .../troubleshooting/troubleshooting-extension.md | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md b/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md index 0e433cca870..b0a7ec85091 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md @@ -31,7 +31,7 @@ Selecting a suggestion inserts the mention and highlights it in the input. File ### Referencing Past Chats -Choosing **Past chats** from the `@` menu opens a searchable picker of sessions in the current workspace, ordered by recency. Search matches session titles and workspace names. Selecting a session inserts a highlighted mention token; when you send the message, that session's current chat history is added as context so the agent can build on the earlier conversation. Clicking the mention token opens the referenced session. +Choosing **Past chats** from the `@` menu opens a searchable picker of sessions in the current workspace and related Git worktrees, including Agent Manager worktrees, ordered by recency. Search matches session titles and worktree names. Selecting a session inserts a highlighted mention token; when you send the message, that session's current chat history is added as context so the agent can build on the earlier conversation. Clicking the mention token opens the referenced session. Very long chat histories are truncated, keeping the beginning and end, so a single mention cannot fill the context window. @@ -77,7 +77,7 @@ This means the agent can explore your entire project as needed, rather than bein | **Mention files when helpful** | If you know the exact file, mention its path to save the agent a search step | | **Keep editor tabs relevant** | Open tabs are passed as context, so keep relevant files open | | **Trust the agent's tools** | The agent can search, read, and explore your codebase — let it do the discovery work | -| **Reference a past chat** | Type `@` and choose **Past chats** to search sessions in the current workspace. Selecting a session adds its current chat history as context when you send the message. | +| **Reference a past chat** | Type `@` and choose **Past chats** to search the current workspace and related Git worktrees, including Agent Manager worktrees. Selecting a session adds its current chat history as context when you send the message. | {% /tab %} {% tab label="CLI" %} diff --git a/packages/kilo-docs/pages/code-with-ai/agents/session-history.md b/packages/kilo-docs/pages/code-with-ai/agents/session-history.md index 4b78b52ae28..02f1c0787ea 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/session-history.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/session-history.md @@ -21,9 +21,9 @@ Different search surfaces cover different content and scopes: | [JetBrains History](#search-session-history-in-jetbrains) | Session titles | Sessions loaded in the selected Local or Cloud history view | | [CLI Past chats](#reference-a-past-chat-in-the-cli-tui) | Session titles | Current workspace | | [`kilo session list --search`](#filter-session-titles) | Session titles | Current workspace, or every local workspace with `--all` | -| [Ask Kilo to recall past chats](#ask-kilo-to-search-past-chats) | Titles and high-signal chat content | Current workspace | +| [Ask Kilo to recall past chats](#ask-kilo-to-search-past-chats) | Titles and high-signal chat content | Current workspace and related Git worktrees | | [VS Code chat search](#search-the-open-chat-session) | Rendered content in the open session | Current chat session | -| [VS Code Past chats](#add-a-past-chat-to-the-current-session) | Session titles and workspace names | Current workspace | +| [VS Code Past chats](#add-a-past-chat-to-the-current-session) | Session titles and worktree names | Current workspace and related Git worktrees, including Agent Manager worktrees | | [SQLite query](#query-sqlite-directly) | Any stored field you select | Entire selected local database | History and CLI list searches do **not** search message content. To find a string inside prompts or replies, ask Kilo to search past chats or use a direct SQLite query. @@ -58,7 +58,7 @@ Kilo loads older messages while the search is active so the search covers the fu ### Add a past chat to the current session -Type `@` in the chat input, select **Past chats**, and search by session title or workspace name. Selecting a result adds that past chat's content as context when you send the message from your current session. It does not switch or reopen sessions. Past chats includes sessions in the current workspace. +Type `@` in the chat input, select **Past chats**, and search by session title or worktree name. Selecting a result adds that past chat's content as context when you send the message from your current session. It does not switch or reopen sessions. Past chats includes sessions in the current workspace and related Git worktrees, including Agent Manager worktrees. {% /tab %} {% tab label="JetBrains" %} @@ -98,7 +98,7 @@ The simplest way to search message content across past local sessions is to ask Search my local sessions for "database disk image is malformed" and summarize the matching conversations. ``` -Kilo's local recall search covers session titles, user and assistant text, file references, and failed tool errors in the current workspace. Every query term must occur somewhere in a matching session; exact phrases and user-authored matches rank higher. +Kilo's local recall search covers session titles, user and assistant text, file references, and failed tool errors in the current workspace and related Git worktrees. Every query term must occur somewhere in a matching session; exact phrases and user-authored matches rank higher. The search includes archived and child sessions. It excludes reasoning, synthetic or ignored text, successful tool output, file contents, and other metadata. Kilo can then read the full chat history for a selected result. @@ -144,8 +144,8 @@ Stable installations normally use these paths: | Environment | Default path | |---|---| -| Windows | `%LOCALAPPDATA%\kilo\kilo.db` | -| macOS | `~/Library/Application Support/kilo/kilo.db` | +| Windows | `%USERPROFILE%\.local\share\kilo\kilo.db` | +| macOS | `~/.local/share/kilo/kilo.db` | | Linux | `~/.local/share/kilo/kilo.db` | | VS Code Remote SSH | `~/.local/share/kilo/kilo.db` on the remote machine | diff --git a/packages/kilo-docs/pages/getting-started/troubleshooting/troubleshooting-extension.md b/packages/kilo-docs/pages/getting-started/troubleshooting/troubleshooting-extension.md index 368d5ac134d..075843369e8 100644 --- a/packages/kilo-docs/pages/getting-started/troubleshooting/troubleshooting-extension.md +++ b/packages/kilo-docs/pages/getting-started/troubleshooting/troubleshooting-extension.md @@ -33,8 +33,8 @@ The default database location depends on where Kilo Code is running: | Environment | Database path | |---|---| -| Windows | `%LOCALAPPDATA%\kilo\kilo.db` | -| macOS | `~/Library/Application Support/kilo/kilo.db` | +| Windows | `%USERPROFILE%\.local\share\kilo\kilo.db` | +| macOS | `~/.local/share/kilo/kilo.db` | | Linux | `~/.local/share/kilo/kilo.db` | | VS Code Remote SSH | `~/.local/share/kilo/kilo.db` on the remote machine | From 48c4a4af227572011bf44c172ab0ae86e0c2a429 Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:20:37 +0000 Subject: [PATCH 31/34] fix(gateway): ignore negative prices in model catalog Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .changeset/ignore-negative-model-prices.md | 7 +++ packages/kilo-gateway/src/api/models.ts | 2 +- packages/kilo-gateway/test/api/models.test.ts | 63 +++++++++++++++++++ .../tests/unit/model-price-format.test.ts | 5 ++ .../components/shared/model-preview-utils.ts | 1 + .../components/model-info-panel-utils.ts | 1 + .../kilocode/model-info-panel-utils.test.ts | 5 ++ 7 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 .changeset/ignore-negative-model-prices.md diff --git a/.changeset/ignore-negative-model-prices.md b/.changeset/ignore-negative-model-prices.md new file mode 100644 index 00000000000..629d5fcc02c --- /dev/null +++ b/.changeset/ignore-negative-model-prices.md @@ -0,0 +1,7 @@ +--- +"@kilocode/kilo-gateway": patch +"@kilocode/cli": patch +"kilo-code": patch +--- + +Ignore negative pricing entries from model catalogs and handle unpriced models gracefully in UI price formatting. diff --git a/packages/kilo-gateway/src/api/models.ts b/packages/kilo-gateway/src/api/models.ts index 8188c1f825b..3ccb4ff2534 100644 --- a/packages/kilo-gateway/src/api/models.ts +++ b/packages/kilo-gateway/src/api/models.ts @@ -76,7 +76,7 @@ type OpenRouterModel = z.infer function parseApiPrice(price: string | null | undefined): number | undefined { if (!price) return undefined const parsed = parseFloat(price) - if (isNaN(parsed)) return undefined + if (isNaN(parsed) || parsed < 0) return undefined return parsed * 1_000_000 // Convert $/token → $/M tokens } diff --git a/packages/kilo-gateway/test/api/models.test.ts b/packages/kilo-gateway/test/api/models.test.ts index 0833ec11f30..7d73b3d1b5e 100644 --- a/packages/kilo-gateway/test/api/models.test.ts +++ b/packages/kilo-gateway/test/api/models.test.ts @@ -383,3 +383,66 @@ test("keeps organization catalog errors from silently falling back to personal m expect(calls[0]).toContain("/api/gateway/transcription-models") expect(headers[0]?.get("X-KILOCODE-ORGANIZATIONID")).toBe("org-1") }) + +test("omits cost when pricing contains negative values (dynamic/auto-routed pricing)", async () => { + const orig = globalThis.fetch + stubFetch( + async () => + new Response( + JSON.stringify({ + data: [ + { + id: "openrouter/auto", + name: "Auto Router", + context_length: 128000, + max_completion_tokens: 16384, + architecture: { + input_modalities: ["text"], + output_modalities: ["text"], + }, + supported_parameters: ["tools"], + pricing: { + prompt: "-1", + completion: "-1", + }, + }, + { + id: "test/fixed-price", + name: "Fixed Price Model", + context_length: 128000, + max_completion_tokens: 16384, + architecture: { + input_modalities: ["text"], + output_modalities: ["text"], + }, + supported_parameters: ["tools"], + pricing: { + prompt: "0.000003", + completion: "0.000015", + input_cache_read: "0.0000003", + input_cache_write: "-1", + }, + }, + ], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ) + + const result = await fetchKiloModels({}) + + ;(globalThis as any).fetch = orig + + expect(result.error).toBeUndefined() + expect(result.models["openrouter/auto"]).toBeDefined() + expect(result.models["openrouter/auto"].cost).toBeUndefined() + expect(result.models["test/fixed-price"].cost).toEqual({ + input: 3, + output: 15, + cache_read: 0.3, + }) +}) + diff --git a/packages/kilo-vscode/tests/unit/model-price-format.test.ts b/packages/kilo-vscode/tests/unit/model-price-format.test.ts index d5aab2d6729..8a01b838094 100644 --- a/packages/kilo-vscode/tests/unit/model-price-format.test.ts +++ b/packages/kilo-vscode/tests/unit/model-price-format.test.ts @@ -18,6 +18,11 @@ describe("fmtPrice", () => { expect(fmtPrice(0)).toBe("Free") }) + it("returns dash for negative price", () => { + expect(fmtPrice(-1)).toBe("—") + expect(fmtPrice(-1000000)).toBe("—") + }) + it("uses 4 decimal places for sub-cent prices", () => { expect(fmtPrice(0.005)).toBe("$0.0050/1M") }) diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/model-preview-utils.ts b/packages/kilo-vscode/webview-ui/src/components/shared/model-preview-utils.ts index 980ab78f368..10cd1f44d76 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/model-preview-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/shared/model-preview-utils.ts @@ -3,6 +3,7 @@ * Expects `n` in $/M tokens (as stored in model.cost.input / model.cost.output). */ export function fmtPrice(n: number): string { + if (n < 0) return "—" if (n === 0) return "Free" if (n < 0.01) return `$${n.toFixed(4)}/1M` return `$${n.toFixed(2)}/1M` diff --git a/packages/opencode/src/kilocode/components/model-info-panel-utils.ts b/packages/opencode/src/kilocode/components/model-info-panel-utils.ts index 1c7bb828b56..5cfc69d7b70 100644 --- a/packages/opencode/src/kilocode/components/model-info-panel-utils.ts +++ b/packages/opencode/src/kilocode/components/model-info-panel-utils.ts @@ -8,6 +8,7 @@ interface Cost { } export function fmtPrice(n: number): string { + if (n < 0) return "—" if (n === 0) return "Free" if (n < 0.01) return `$${n.toFixed(4)}/1M` return `$${n.toFixed(2)}/1M` diff --git a/packages/opencode/test/kilocode/model-info-panel-utils.test.ts b/packages/opencode/test/kilocode/model-info-panel-utils.test.ts index 8a07fcb2291..f24f2b3ba85 100644 --- a/packages/opencode/test/kilocode/model-info-panel-utils.test.ts +++ b/packages/opencode/test/kilocode/model-info-panel-utils.test.ts @@ -6,6 +6,11 @@ describe("model info panel price formatting", () => { expect(fmtPrice(0)).toBe("Free") }) + test("fmtPrice returns dash for negative values", () => { + expect(fmtPrice(-1)).toBe("—") + expect(fmtPrice(-1000000)).toBe("—") + }) + test("fmtPrice uses four decimals for very small prices", () => { expect(fmtPrice(0.0095)).toBe("$0.0095/1M") }) From 08f8c5fc5937063f1e01e73526ce224bb09a71fb Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:28:01 +0000 Subject: [PATCH 32/34] fix(ui): hide pricing section for unpriced models Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .changeset/ignore-negative-model-prices.md | 1 + .../session/ui/model/ModelDetailsPanel.kt | 6 +-- .../src/components/shared/ModelPreview.tsx | 9 +++-- .../kilocode/components/model-info-panel.tsx | 37 ++++++++++--------- 4 files changed, 29 insertions(+), 24 deletions(-) diff --git a/.changeset/ignore-negative-model-prices.md b/.changeset/ignore-negative-model-prices.md index 629d5fcc02c..66bcf01a6dd 100644 --- a/.changeset/ignore-negative-model-prices.md +++ b/.changeset/ignore-negative-model-prices.md @@ -2,6 +2,7 @@ "@kilocode/kilo-gateway": patch "@kilocode/cli": patch "kilo-code": patch +"@kilocode/kilo-jetbrains": patch --- Ignore negative pricing entries from model catalogs and handle unpriced models gracefully in UI price formatting. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelDetailsPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelDetailsPanel.kt index eb83cf748e6..b6541f267a6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelDetailsPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelDetailsPanel.kt @@ -157,14 +157,14 @@ internal class ModelDetailsPanel( return buildList { item.releaseDate?.let { add(KiloBundle.message("model.picker.details.released") to date(it)) } if (!item.free) { - item.cost?.let { cost -> + item.cost?.takeIf { it.input > 0.0 || it.output > 0.0 }?.let { cost -> add(KiloBundle.message("model.picker.details.input") to price(cost.input)) add(KiloBundle.message("model.picker.details.output") to price(cost.output)) add(KiloBundle.message("model.picker.details.cached") to cached(cost.input, cost.cache?.read)) add(KiloBundle.message("model.picker.details.average") to price(average(cost.input, cost.output, cost.cache?.read))) } ?: run { - item.inputPrice?.let { add(KiloBundle.message("model.picker.details.input") to price(it)) } - item.outputPrice?.let { add(KiloBundle.message("model.picker.details.output") to price(it)) } + item.inputPrice?.takeIf { it > 0.0 }?.let { add(KiloBundle.message("model.picker.details.input") to price(it)) } + item.outputPrice?.takeIf { it > 0.0 }?.let { add(KiloBundle.message("model.picker.details.output") to price(it)) } } } ctx?.let { add(KiloBundle.message("model.picker.details.context") to context(it)) } diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx index a88bc752668..163cd5adf30 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx @@ -50,12 +50,13 @@ export const ModelPreview: Component = (props) => { {(model) => { const cost = () => model().cost + const hasPricing = () => cost() && (cost()!.input > 0 || cost()!.output > 0) const bench = () => model().terminalBench const cachedText = () => { - if (!cost()) return "" + if (!cost() || !hasPricing()) return "" return fmtCachedPrice(cost()!) ?? language.t("model.preview.value.notSupported") } - const avg = () => (cost() ? avgPrice(cost()!) : undefined) + const avg = () => (cost() && hasPricing() ? avgPrice(cost()!) : undefined) const freeLabel = () => language.t("model.tag.free") const dataLabel = () => freeDataLabel(language.t("model.tag.free"), language.t("model.tag.dataCollected")) const autoLabel = () => autoSummary(model()) @@ -139,8 +140,8 @@ export const ModelPreview: Component = (props) => { {fmtDate(model().releaseDate!)} - {/* Pricing — hidden for free models */} - + {/* Pricing — hidden for free models or models without fixed pricing */} + {language.t("model.preview.label.input")} {fmtPrice(cost()!.input)} {language.t("model.preview.label.output")} diff --git a/packages/opencode/src/kilocode/components/model-info-panel.tsx b/packages/opencode/src/kilocode/components/model-info-panel.tsx index e7fa1893499..8ed65dda997 100644 --- a/packages/opencode/src/kilocode/components/model-info-panel.tsx +++ b/packages/opencode/src/kilocode/components/model-info-panel.tsx @@ -27,8 +27,9 @@ export function ModelInfoPanel(props: Props) { const maxHeight = createMemo(() => Math.floor(dimensions().height / 2) - 3) const cost = () => m().cost - const cached = () => (cost() ? fmtCachedPrice(cost()) : null) - const avg = () => (cost() ? avgPrice(cost()) : undefined) + const hasPricing = () => m().cost?.input > 0 || m().cost?.output > 0 + const cached = () => (cost() && hasPricing() ? fmtCachedPrice(cost()) : null) + const avg = () => (cost() && hasPricing() ? avgPrice(cost()) : undefined) const caps = () => m().capabilities const inputs = () => caps()?.input const outputs = () => caps()?.output @@ -99,25 +100,27 @@ export function ModelInfoPanel(props: Props) { - - Input - {m() ? fmtPrice(m().cost.input) : "—"} - - - Output - {m() ? fmtPrice(m().cost.output) : "—"} - - + - Cached - {cached()} + Input + {m() ? fmtPrice(m().cost.input) : "—"} - - - Avg Cost - {m() ? fmtPrice(avg()!) : "—"} + Output + {m() ? fmtPrice(m().cost.output) : "—"} + + + Cached + {cached()} + + + + + Avg Cost + {m() ? fmtPrice(avg()!) : "—"} + + Context From 73844ae68d27d095681c2369a8093156b106a453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 10 Aug 2026 12:15:30 +0100 Subject: [PATCH 33/34] feat(cli): answer sessionless model catalog requests (#13014) * feat(cli): answer sessionless model catalog requests * feat(cli): add sessionless catalog changeset * chore(cli): format remote sender files * test(cli): simplify sessionless catalog coverage * fix(test): extend config overlay timeout The legacy overlay test can exceed 30 seconds on a cold Windows runner while its assertions remain valid. --- .changeset/remote-instance-catalog.md | 5 ++ .../src/kilo-sessions/remote-sender.ts | 27 +++---- .../kilocode/server/config-overlay.test.ts | 4 +- .../kilocode/sessions/remote-sender.test.ts | 79 ++++++++++++++++--- 4 files changed, 90 insertions(+), 25 deletions(-) create mode 100644 .changeset/remote-instance-catalog.md diff --git a/.changeset/remote-instance-catalog.md b/.changeset/remote-instance-catalog.md new file mode 100644 index 00000000000..bed43e8cd31 --- /dev/null +++ b/.changeset/remote-instance-catalog.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Support browsing an instance's model catalog before a session starts. diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts index 288be081c43..d49babd8699 100644 --- a/packages/opencode/src/kilo-sessions/remote-sender.ts +++ b/packages/opencode/src/kilo-sessions/remote-sender.ts @@ -345,9 +345,7 @@ export namespace RemoteSender { // bus listener count from inflating for senders that never handle // attachments (the count would otherwise show up in unrelated tests // that assert it stays at 0). - const attachments = - options.attachments ?? - ((sessionID: SessionID) => RemoteAttachments.create({ sessionID })) + const attachments = options.attachments ?? ((sessionID: SessionID) => RemoteAttachments.create({ sessionID })) const attachmentCache = new Map() const pending = new Map() const retired = new Map() @@ -886,27 +884,27 @@ export namespace RemoteSender { return } // kilocode_change end + // kilocode_change start - sessionless list_models for the pre-session instance picker if (msg.command === "list_models") { const parsed = RemoteModelCatalog.Request.safeParse(msg.data) - const session = msg.sessionId ? decodeSessionID(msg.sessionId) : Option.none() - if (!parsed.success || Option.isNone(session)) { - options.conn.send({ - type: "response", - id: msg.id, - error: "invalid list_models command", - }) + // Accept an absent sessionId (the mobile instance-picker path asks for the + // instance's catalog before a session exists). A present but undecodable + // sessionId is still invalid. + const target = msg.sessionId ? decodeSessionID(msg.sessionId) : Option.none() + if (!parsed.success || (msg.sessionId && Option.isNone(target))) { + options.conn.send({ type: "response", id: msg.id, error: "invalid list_models command" }) return } const run = options.provide ?? provide void (async () => { try { - const info = await catalog.get(session.value) + const info = Option.isSome(target) ? await catalog.get(target.value) : undefined const result = await run({ - directory: info.directory, + directory: info?.directory ?? options.directory, fn: async () => { const [providers, messages, fallback] = await Promise.all([ catalog.providers(), - catalog.messages(info.id), + info ? catalog.messages(info.id) : Promise.resolve([]), catalog.default().catch((err) => { options.log.warn("default model lookup failed", { error: String(err) }) return undefined @@ -914,7 +912,7 @@ export namespace RemoteSender { ]) return RemoteModelCatalog.build({ providers, - session: info, + session: info ?? {}, messages, defaultModel: fallback, }) @@ -928,6 +926,7 @@ export namespace RemoteSender { })() return } + // kilocode_change end if (msg.command === "send_message") { const parsed = getRemotePromptInput().safeParse(msg.data) if (!parsed.success) { diff --git a/packages/opencode/test/kilocode/server/config-overlay.test.ts b/packages/opencode/test/kilocode/server/config-overlay.test.ts index f87246fb360..50c8e1667a5 100644 --- a/packages/opencode/test/kilocode/server/config-overlay.test.ts +++ b/packages/opencode/test/kilocode/server/config-overlay.test.ts @@ -911,7 +911,9 @@ describe("config overlay routes", () => { Permission.evaluate("edit", "*", after.find((item) => item.name === "code")?.permission ?? []).action, ).toBe("allow") }, - 30_000, + // Cold Windows CI runs take ~32s (observed timeout at 30s); give the two + // instance create/dispose cycles of each iteration real headroom. + 90_000, ) } }) diff --git a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts index e8683fe49e8..44504702f37 100644 --- a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts @@ -1084,7 +1084,7 @@ describe("RemoteSender", () => { expect(JSON.stringify(sent)).not.toContain("api-key") }) - test("list_models rejects unsupported versions and missing session IDs", () => { + test("list_models rejects unsupported versions and undecodable session IDs", () => { const { conn, sent } = fakeConn() const sender = RemoteSender.create({ conn, @@ -1100,12 +1100,6 @@ describe("RemoteSender", () => { sessionId: "ses_models", data: { protocolVersion: 2 }, }) - sender.handle({ - type: "command", - id: "req_models_missing_session", - command: "list_models", - data: { protocolVersion: 1 }, - }) sender.handle({ type: "command", id: "req_models_invalid_session", @@ -1116,11 +1110,72 @@ describe("RemoteSender", () => { expect(sent).toEqual([ { type: "response", id: "req_models_v2", error: "invalid list_models command" }, - { type: "response", id: "req_models_missing_session", error: "invalid list_models command" }, { type: "response", id: "req_models_invalid_session", error: "invalid list_models command" }, ]) }) + test("list_models without a sessionId returns the instance catalog", async () => { + const { conn, sent } = fakeConn() + const dirs: string[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; init?: Effect.Effect; fn: () => R }) => { + dirs.push(input.directory) + return input.fn() + }, + catalog: { + get: async () => { + throw new Error("catalog.get must not be called without a sessionId") + }, + messages: async () => { + throw new Error("catalog.messages must not be called without a sessionId") + }, + providers: async () => + ({ + custom: { + id: ProviderV2.ID.make("custom"), + name: "Custom Provider", + source: "config", + env: ["PRIVATE_API_KEY"], + key: "must-not-leak", + options: { apiKey: "must-not-leak" }, + models: { + "deployment/model": catalogModel("custom", "deployment/model", "Deployment Model", true), + }, + }, + }) as any, + default: async () => ({ + providerID: ProviderV2.ID.make("custom"), + modelID: ModelV2.ID.make("deployment/model"), + }), + }, + }) + + sender.handle({ + type: "command", + id: "req_models_sessionless", + command: "list_models", + data: { protocolVersion: 1 }, + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(dirs).toEqual(["/tmp/process-default"]) + expect(sent).toHaveLength(1) + expect(sent[0]?.type).toBe("response") + expect(sent[0]?.id).toBe("req_models_sessionless") + const result = sent[0]?.result as RemoteModelCatalog.Response + expect(result.protocolVersion).toBe(1) + expect(result.all).toHaveLength(1) + expect(result.all[0]?.id).toBe("custom") + expect(result.defaultModel).toEqual({ providerID: "custom", modelID: "deployment/model" }) + expect(result).not.toHaveProperty("currentModel") + expect(JSON.stringify(result)).not.toContain("must-not-leak") + }) + test("send_message with agent is accepted", async () => { const { conn, sent } = fakeConn() let resolveProvide: () => void @@ -3066,7 +3121,9 @@ describe("RemoteSender slash commands", () => { removeCalls.push(id) }, }, - attachSession: async () => { throw new Error("attach failed") }, + attachSession: async () => { + throw new Error("attach failed") + }, }) const response = expectResponse(conn, sent, "req_spawn_failed") @@ -3107,7 +3164,9 @@ describe("RemoteSender slash commands", () => { throw new Error("cleanup secondary failure") }, }, - attachSession: async () => { throw new Error("attach failed") }, + attachSession: async () => { + throw new Error("attach failed") + }, }) const response = expectResponse(conn, sent, "req_spawn_then_cleanup_fail") From fdc46654c5860c01276d7034ba715f779b163bca Mon Sep 17 00:00:00 2001 From: Johnny Eric Amancio Date: Thu, 6 Aug 2026 18:08:52 +0200 Subject: [PATCH 34/34] fix(memory): quiet transient capture timeouts and stop same-turn retries --- .../fix-memory-model-timeout-warnings.md | 7 + .../kilo-memory/src/capture/operations.ts | 9 +- packages/kilo-memory/src/capture/outcome.ts | 73 +++++++++- packages/kilo-memory/src/effect/capture.ts | 122 +++-------------- packages/kilo-memory/src/effect/service.ts | 23 +--- packages/kilo-memory/src/memory-notice.ts | 24 +--- packages/kilo-memory/src/memory.ts | 59 -------- packages/kilo-memory/src/recall/indexer.ts | 1 - packages/kilo-memory/src/recall/shared.ts | 1 + packages/kilo-memory/src/schema.ts | 6 + packages/kilo-memory/src/storage/audit.ts | 44 +----- packages/kilo-memory/src/storage/state.ts | 11 +- packages/kilo-memory/src/tool.ts | 129 ++---------------- packages/kilo-memory/test/capture.test.ts | 84 ++++++++++-- packages/kilo-memory/test/core.test.ts | 16 +++ .../kilo-memory/test/effect-capture.test.ts | 127 ++++++++++++++++- packages/kilo-vscode/src/KiloProvider.ts | 7 +- .../unit/kilo-provider-memory-events.test.ts | 37 +++++ .../src/kilocode/cli/cmd/tui/memory-events.ts | 3 + .../opencode/src/kilocode/memory/ports.ts | 3 +- .../cli/cmd/tui/memory-command.test.ts | 13 +- .../test/kilocode/memory/memory-ports.test.ts | 48 ++++++- 22 files changed, 446 insertions(+), 401 deletions(-) create mode 100644 .changeset/fix-memory-model-timeout-warnings.md diff --git a/.changeset/fix-memory-model-timeout-warnings.md b/.changeset/fix-memory-model-timeout-warnings.md new file mode 100644 index 00000000000..5a0c8700162 --- /dev/null +++ b/.changeset/fix-memory-model-timeout-warnings.md @@ -0,0 +1,7 @@ +--- +"@kilocode/cli": patch +"@kilocode/kilo-memory": patch +"kilo-code": patch +--- + +Reduce noisy memory timeout warnings and retry transient background consolidation failures once. diff --git a/packages/kilo-memory/src/capture/operations.ts b/packages/kilo-memory/src/capture/operations.ts index f4703fdd160..53a1dcf345f 100644 --- a/packages/kilo-memory/src/capture/operations.ts +++ b/packages/kilo-memory/src/capture/operations.ts @@ -126,7 +126,7 @@ export namespace MemoryOperations { const adds = input.ops .filter((item): item is Add => item.action === "add") // Redact rejected text too: this filter runs before the secret one, so a rejected op never - // reaches it, and skips flow into the persistent decisions audit (/memory/show, TUI). + // reaches it, and skips remain visible in the operation result. .filter((op) => { const item = reject(op) if (!item) return true @@ -314,7 +314,7 @@ export namespace MemoryOperations { } } - async function persist(input: { root: string; state: MemorySchema.State; count: number; removed: number }) { + async function persist(input: { root: string; state: MemorySchema.State; count: number }) { const index = await MemoryIndexer.rebuild({ root: input.root, state: input.state }) await MemoryFiles.writeState(input.root, { ...input.state, @@ -323,7 +323,6 @@ export namespace MemoryOperations { lastOperationCount: input.count, }, }) - await MemoryFiles.append(input.root, `apply ops=${input.count} removed=${input.removed}`) return index } @@ -365,9 +364,9 @@ export namespace MemoryOperations { const prepared = prepare({ state, ops: input.ops, max: state.limits.maxLineChars }) const removes = input.ops.filter((item): item is Remove => item.action === "remove") const plan = planOps({ docs, inventory, removes, adds: prepared.adds, now: Date.now() }) - // Commit (IO): write changed documents, then rebuild the index, persist state, and audit. + // Commit (IO): write changed documents, then rebuild the index and persist state. await writeDocs({ root: input.root, plan }) - const index = await persist({ root: input.root, state, count: plan.count, removed: plan.removed }) + const index = await persist({ root: input.root, state, count: plan.count }) return { operationCount: plan.count, added: plan.added, diff --git a/packages/kilo-memory/src/capture/outcome.ts b/packages/kilo-memory/src/capture/outcome.ts index 9ee4be803d7..ce65e172862 100644 --- a/packages/kilo-memory/src/capture/outcome.ts +++ b/packages/kilo-memory/src/capture/outcome.ts @@ -1,9 +1,12 @@ import { MemoryOperations } from "./operations" import { MemoryRedact } from "./redact" import { MemoryShared } from "../recall/shared" +import { TRANSIENT } from "../schema" import type { MemoryFiles } from "../storage/store" import type { CaptureSkip } from "./parse" +export { TRANSIENT } + export type CaptureSourceItem = { id: string text: string @@ -69,14 +72,75 @@ export function errorReason(err: unknown) { return MemoryShared.brief(MemoryRedact.text(parts.join(" ")), 500) } -export function guardReason(input: string) { - const value = input.toLowerCase() - if (/\b(429|rate[_ -]?limit|too many requests)\b/.test(value)) return "rate_limit_guard" - if (/\b(insufficient[_ -]?quota|quota exceeded|exceeded your quota|billing|credits?|credit balance)\b/.test(value)) +const RATE_TEXT = /\b(429|rate[_ -]?limit|too many requests)\b/ +const QUOTA_TEXT = /\b(insufficient[_ -]?quota|quota exceeded|exceeded your quota|billing|credits?|credit balance)\b/ +const TIMEOUT_TEXT = + /\b(timeouterror|etimedout|deadline[ _-]?exceeded|timed out|(connect|headers|body|gateway)[ _-]?time[ -]?out)\b|^timeout$/ +const TIMEOUT_CODES = new Set(["ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]) + +/** Structural timeout detection over an error's name/status/code, its message text, and its nested + * cause/errors chains (cycle-safe). Message matching is limited to `message` — never the serialized + * body/data blobs — so response payloads that merely mention "timeout" cannot classify as transient. */ +function timedOut(item: unknown, seen = new WeakSet()): boolean { + if (!item || typeof item !== "object" || seen.has(item)) return false + seen.add(item) + const err = item as { + name?: unknown + code?: unknown + status?: unknown + statusCode?: unknown + message?: unknown + cause?: unknown + errors?: unknown + } + if (err.name === "TimeoutError") return true + if (err.status === 504 || err.statusCode === 504) return true + if (typeof err.code === "string" && TIMEOUT_CODES.has(err.code)) return true + if (typeof err.message === "string" && TIMEOUT_TEXT.test(err.message.toLowerCase())) return true + if (timedOut(err.cause, seen)) return true + return Array.isArray(err.errors) && err.errors.some((entry) => timedOut(entry, seen)) +} + +function rateGuarded(item: unknown, seen = new WeakSet()): boolean { + if (!item || typeof item !== "object" || seen.has(item)) return false + seen.add(item) + const err = item as { + status?: unknown + statusCode?: unknown + message?: unknown + cause?: unknown + errors?: unknown + } + if (err.status === 429 || err.statusCode === 429) return true + if (typeof err.message === "string" && RATE_TEXT.test(err.message.toLowerCase())) return true + if (rateGuarded(err.cause, seen)) return true + return Array.isArray(err.errors) && err.errors.some((entry) => rateGuarded(entry, seen)) +} + +function quotaGuarded(item: unknown, seen = new WeakSet()): boolean { + if (!item || typeof item !== "object" || seen.has(item)) return false + seen.add(item) + const err = item as { + message?: unknown + cause?: unknown + errors?: unknown + } + if (typeof err.message === "string" && QUOTA_TEXT.test(err.message.toLowerCase())) return true + if (quotaGuarded(err.cause, seen)) return true + return Array.isArray(err.errors) && err.errors.some((entry) => quotaGuarded(entry, seen)) +} + +export function guardReason(input: unknown) { + const value = (typeof input === "string" ? input : errorReason(input)).toLowerCase() + if (typeof input === "string" ? RATE_TEXT.test(value) : rateGuarded(input) || RATE_TEXT.test(value)) + return "rate_limit_guard" + if (typeof input === "string" ? QUOTA_TEXT.test(value) : quotaGuarded(input) || QUOTA_TEXT.test(value)) return "quota_guard" + if (typeof input === "string" ? TIMEOUT_TEXT.test(value) : timedOut(input)) return TRANSIENT return undefined } +/** @deprecated Memory audit persistence was removed. */ export function skipped(input: { sessionID: string; reason: string }): MemoryFiles.Decision { return { kind: "typed", @@ -94,6 +158,7 @@ export function skipped(input: { sessionID: string; reason: string }): MemoryFil } } +/** @deprecated Memory audit persistence was removed. */ export function auditOps(ops: MemoryOperations.Op[]) { return MemoryShared.audit(ops) } diff --git a/packages/kilo-memory/src/effect/capture.ts b/packages/kilo-memory/src/effect/capture.ts index fda14a1a19a..cd0e1308d20 100644 --- a/packages/kilo-memory/src/effect/capture.ts +++ b/packages/kilo-memory/src/effect/capture.ts @@ -1,6 +1,5 @@ import { Cause, Effect } from "effect" import { - auditOps, cap, capturePlan, digestPrompt, @@ -18,9 +17,9 @@ import { parseJson, parseOps, salvageTyped, - skipped, summarize, summarizeDiffs, + TRANSIENT, typedPrompt, usage, verifySkips, @@ -105,11 +104,16 @@ export namespace MemoryCapture { yield* memory.prepare({ root }) const state = yield* memory.state({ root }) const reported = new Set() - const fail = (reason: string) => + const fail = (reason: string, detail?: string) => Effect.promise(async () => { const safe = MemoryRedact.text(reason) if (reported.has(safe)) return reported.add(safe) + if (reason === TRANSIENT) + MemoryLog.warn("memory capture transient failure", { + reason: safe, + detail: MemoryShared.brief(MemoryRedact.text(detail ?? safe), 160), + }) await MemoryEvents.publish({ event: "error", payload: MemoryEvents.status({ @@ -123,7 +127,6 @@ export namespace MemoryCapture { }) const skip = (reason: string, opts?: { idleFlush?: boolean }) => Effect.gen(function* () { - if (state.enabled) yield* memory.decide({ root, decision: skipped({ sessionID: input.sessionID, reason }) }) yield* Effect.promise(() => MemoryEvents.publish({ event: "status", @@ -203,23 +206,6 @@ export namespace MemoryCapture { tokens: 0, fallback: true, }) - yield* memory.decide({ - root, - decision: { - kind: "digest", - trigger: "turn-close", - sessionID: input.sessionID, - result: "fallback", - llm: false, - parsed: false, - fallback: true, - reason: input.reason, - tokens: 0, - operationCount: 1, - skippedCount: 0, - summary: `session digest fallback on ${input.reason ?? "close"}`, - }, - }) } return yield* skip(plan.skipReason, plan.idleFlush ? { idleFlush: true } : undefined) } @@ -232,22 +218,12 @@ export namespace MemoryCapture { const model = digestDue || typedCall - ? yield* Effect.gen(function* () { - const resolution = yield* input.model.resolve({ + ? ( + yield* input.model.resolve({ configured: input.memoryModel, session: view.sessionModel, }) - if (resolution.fallback) { - yield* memory.append({ - root, - text: `memory_model_config reason=${MemoryShared.brief( - MemoryRedact.text(resolution.fallback.reason), - 160, - )} fallback=1`, - }) - } - return resolution.handle - }) + ).handle : undefined const digestEffect = digestDue ? Effect.gen(function* () { @@ -277,9 +253,8 @@ export namespace MemoryCapture { Effect.gen(function* () { if (signal.aborted) return { ok: false as const, reason: "cancelled" } const raw = errorReason(err) - const reason = MemoryRedact.text(guardReason(raw) ?? raw) - yield* fail(reason) - yield* memory.append({ root, text: `digest error=${MemoryShared.brief(reason, 160)} fallback=1` }) + const reason = MemoryRedact.text(guardReason(err) ?? raw) + yield* fail(reason, raw) return { ok: false as const, reason } }), ), @@ -296,14 +271,9 @@ export namespace MemoryCapture { try: () => parseJson(digestSchema, result.result.text), catch: (error) => error, }).pipe( - Effect.catch((err: unknown) => + Effect.catch(() => Effect.gen(function* () { - const reason = MemoryRedact.text(errorReason(err)) yield* fail("digest parse_error") - yield* memory.append({ - root, - text: `digest parse_error=${MemoryShared.brief(reason, 160)} fallback=1`, - }) return undefined }), ), @@ -402,9 +372,8 @@ export namespace MemoryCapture { Effect.gen(function* () { if (signal.aborted) return { ok: false as const, reason: "cancelled" } const raw = errorReason(err) - const reason = MemoryRedact.text(guardReason(raw) ?? raw) - yield* fail(reason) - yield* memory.append({ root, text: `consolidate error=${MemoryShared.brief(reason, 160)}` }) + const reason = MemoryRedact.text(guardReason(err) ?? raw) + yield* fail(reason, raw) return { ok: false as const, reason } }), ), @@ -423,11 +392,9 @@ export namespace MemoryCapture { try: () => salvageTyped(result.result.text), catch: (error) => error, }).pipe( - Effect.catch((err: unknown) => + Effect.catch(() => Effect.gen(function* () { - const reason = MemoryRedact.text(errorReason(err)) yield* fail("consolidate parse_error") - yield* memory.append({ root, text: `consolidate parse_error=${MemoryShared.brief(reason, 160)}` }) return undefined }), ), @@ -475,30 +442,6 @@ export namespace MemoryCapture { fallback: Boolean(digest.reason), }) } - if (digestDue) { - yield* memory.decide({ - root, - decision: { - kind: "digest", - trigger: "turn-close", - sessionID: input.sessionID, - result: digest.reason ? "fallback" : digest.summary ? "saved" : "skipped", - llm: true, - parsed: Boolean(digest.summary && !digest.reason), - fallback: Boolean(digest.reason), - reason: digest.reason, - tokens: digest.tokens, - operationCount: digest.summary ? 1 : 0, - skippedCount: digest.summary ? 0 : 1, - summary: digest.reason - ? `session digest used fallback after ${digest.reason}` - : digest.summary - ? "session digest saved" - : "session digest skipped", - }, - }) - } - // Apply adds only: a same-key add supersedes/updates an existing fact in place. reconcile also // surfaces exact-key auto-removes, but V0 keeps hard removes explicit-only — auto-capture never // deletes memory it merely paraphrased (or wrongly flags), so reconciled.removes is not applied. @@ -506,39 +449,11 @@ export namespace MemoryCapture { const ops = reconciled.ops.slice(0, state.capture.maxOpsPerRun) const project = ops.length > 0 ? yield* memory.apply({ root, ops, trigger: "turn-close", tokens: generated.tokens }) : undefined - // Apply-time skips (content gate + secret, both redacted at creation) surface in the typed audit - // record alongside the model's own declared skips. const applied: CaptureSkip[] = [...generated.skipped, ...(project?.skipped ?? [])] const count = project?.operationCount ?? 0 - if (typedCall) { - yield* memory.decide({ - root, - decision: { - kind: "typed", - trigger: "turn-close", - sessionID: input.sessionID, - result: generated.fallback ? "fallback" : count > 0 ? "saved" : "skipped", - llm: true, - parsed: !generated.fallback, - fallback: generated.fallback, - reason: generated.reason, - tokens: generated.tokens, - operationCount: count, - skippedCount: applied.length, - skipped: applied, - operations: auditOps(ops), - files: [...new Set(ops.flatMap((item) => (item.action === "add" && item.file ? [item.file] : [])))], - summary: generated.fallback - ? `typed consolidation skipped after ${generated.reason ?? "model failure"}` - : count > 0 - ? `typed consolidation saved ${count} ops` - : `typed consolidation skipped ${applied.length} candidates`, - }, - }) - } const tokens = digest.tokens + generated.tokens if (!digest.summary && !typedCall && count === 0) return yield* skip("no_ops") - if ((digestDue || typedCall || count > 0) && (!typedCall || !generated.fallback)) { + if (digestDue || typedCall || count > 0) { yield* memory.commit({ root, now, @@ -546,8 +461,7 @@ export namespace MemoryCapture { tokens, count, digest: Boolean(digest.summary), - typed: typedCall, - skipped: applied, + typed: typedCall && !generated.fallback, }) } const updated = yield* memory.state({ root }) diff --git a/packages/kilo-memory/src/effect/service.ts b/packages/kilo-memory/src/effect/service.ts index 9f705499360..026553a61a3 100644 --- a/packages/kilo-memory/src/effect/service.ts +++ b/packages/kilo-memory/src/effect/service.ts @@ -1,5 +1,4 @@ import { Context, Effect, Layer, Semaphore } from "effect" -import { skipLine, type CaptureSkip } from "../capture/capture" import type { Memory } from "../memory" import type { MemoryOperations } from "../capture/operations" import { MemoryRecall } from "../recall/recall" @@ -104,7 +103,6 @@ type CommitInput = RootInput & { // shared typed-interval clock (lastTypedConsolidationAt); a digest-only commit must leave it untouched so a // digest in one session cannot throttle another session's typed capture. typed: boolean - skipped: CaptureSkip[] cost?: number } @@ -156,10 +154,12 @@ export namespace MemoryService { readonly recent: ( input: RecentInput, ) => Effect.Effect>, Failure> + /** @deprecated Memory audit persistence was removed. */ readonly append: (input: AppendInput) => Effect.Effect readonly index: (input: RootInput) => Effect.Effect readonly commit: (input: CommitInput) => Effect.Effect readonly recordRecall: (input: RecordRecallInput) => Effect.Effect + /** @deprecated Memory audit persistence was removed. */ readonly decide: (input: DecideInput) => Effect.Effect readonly readSource: (input: ReadSourceInput) => Effect.Effect readonly turnLock: (sessionID: SessionID) => Semaphore.Semaphore @@ -200,7 +200,7 @@ export namespace MemoryService { return Object.fromEntries(entries) as Sources }), recent: (input) => bridge(() => MemoryFiles.recentSessions(input.root, input.limit, input.max)), - append: (input) => bridge(() => MemoryFiles.append(input.root, input.text)), + append: () => Effect.void, index: (input) => bridge(async () => { const text = await MemoryFiles.readIndex(input.root) @@ -219,20 +219,11 @@ export namespace MemoryService { lastSessionSavedAt: input.digest ? input.now : state.stats.lastSessionSavedAt, lastConsolidatedMessageID: input.messageID, lastConsolidationCost: input.cost ?? state.stats.lastConsolidationCost, - lastConsolidationTokens: input.tokens, - lastOperationCount: input.count, + lastConsolidationTokens: + input.typed || input.digest ? input.tokens : state.stats.lastConsolidationTokens, + lastOperationCount: input.typed ? input.count : state.stats.lastOperationCount, }, }) - const skip = skipLine(input.skipped) - await MemoryFiles.append( - input.root, - [ - `consolidate trigger=turn-close digest=${input.digest ? 1 : 0} ops=${input.count} tokens=${input.tokens}`, - skip, - ] - .filter(Boolean) - .join(" "), - ) }), ), recordRecall: (input) => @@ -266,7 +257,7 @@ export namespace MemoryService { }), }) }), - decide: (input) => bridge(() => MemoryFiles.decide(input.root, input.decision)), + decide: () => Effect.void, readSource: (input) => bridge(() => MemoryFiles.readSource(input.root, input.file)), // Ref-counted so every acquirer — in-flight or queued behind `withPermits` — shares one // semaphore. Each call must be balanced by exactly one `dropLock`. diff --git a/packages/kilo-memory/src/memory-notice.ts b/packages/kilo-memory/src/memory-notice.ts index f96bca8eec7..983b4613879 100644 --- a/packages/kilo-memory/src/memory-notice.ts +++ b/packages/kilo-memory/src/memory-notice.ts @@ -1,23 +1,12 @@ import { MemoryShared } from "./recall/shared" import type { MemoryOperations } from "./capture/operations" -import { MemoryRedact } from "./capture/redact" -/** Human-facing messages and audit views describing an explicit apply result. */ +/** Human-facing messages describing an explicit apply result. */ export namespace MemoryNotice { export function saved(input: { added: number; removed: number }) { return input.removed > 0 || input.added > 0 } - export function summary(input: { added: number; removed: number; count: number }) { - if (input.added > 0 && input.removed > 0) { - return `explicit memory operation saved ${input.added} and removed ${input.removed}` - } - if (input.added > 0) return `explicit memory operation saved ${input.added} ops` - if (input.removed > 0) return `explicit memory operation removed ${input.removed} entries` - if (input.count > 0) return "explicit memory operation matched no source memory" - return "explicit memory operation had no accepted ops" - } - export function message(input: { ops: MemoryOperations.Op[]; added: number; removed: number; count: number }) { const refs = MemoryShared.refs(input.ops) if (input.added > 0 && input.removed > 0) return `Memory updated · ${input.added} saved, ${input.removed} removed` @@ -25,15 +14,4 @@ export namespace MemoryNotice { if (input.removed > 0) return `Memory updated · ${input.removed} removed` return `Memory unchanged · ${input.count} ops` } - - export function skip(input: MemoryOperations.Rejection[]) { - return input.map((item) => (item.reason === "out_of_scope" ? { reason: item.reason } : item)) - } - - export function ops(input: { ops: MemoryOperations.Op[]; skipped: MemoryOperations.Rejection[] }) { - const blocked = new Set(input.skipped.filter((item) => item.reason === "out_of_scope").map((item) => item.text)) - return MemoryShared.audit( - input.ops.filter((item) => item.action !== "add" || !blocked.has(MemoryRedact.text(item.text))), - ) - } } diff --git a/packages/kilo-memory/src/memory.ts b/packages/kilo-memory/src/memory.ts index 8bd14dbf421..267b92093af 100644 --- a/packages/kilo-memory/src/memory.ts +++ b/packages/kilo-memory/src/memory.ts @@ -8,7 +8,6 @@ import { MemorySchema } from "./schema" import { MemoryShared } from "./recall/shared" import { MemoryToken } from "./recall/token" import { MemorySlug } from "./slug" -import { MemoryRedact } from "./capture/redact" /** Root-bound package facade. External Kilo surfaces should derive root from workspace context first. */ export namespace Memory { @@ -97,7 +96,6 @@ export namespace Memory { const state = await MemoryFiles.readState(input.root) const next = { ...state, enabled: false } await MemoryFiles.writeState(input.root, next) - await MemoryFiles.append(input.root, `disable ${next.scope} source=command`) return { root: input.root, state: next } }) } @@ -124,16 +122,6 @@ export namespace Memory { ...(input.settings.verbose === undefined ? {} : { verbose: input.settings.verbose }), } await MemoryFiles.writeState(input.root, next) - await MemoryFiles.append( - input.root, - [ - `settings ${next.scope}`, - input.settings.autoConsolidate === undefined ? "" : `autoConsolidate=${next.autoConsolidate}`, - input.settings.verbose === undefined ? "" : `verbose=${next.verbose}`, - ] - .filter(Boolean) - .join(" "), - ) return { root: input.root, state: next } }) } @@ -216,24 +204,6 @@ export namespace Memory { } const state = await MemoryFiles.readState(input.root) const ok = MemoryNotice.saved({ added: result.added, removed: result.removed }) - if (trigger === "explicit") { - await MemoryFiles.decide(input.root, { - kind: "typed", - trigger, - sessionID: input.sessionID, - result: ok ? "saved" : "skipped", - llm: false, - parsed: true, - fallback: false, - tokens: input.tokens ?? 0, - operationCount: result.operationCount, - skippedCount: result.skipped.length || (ok ? 0 : 1), - skipped: MemoryNotice.skip(result.skipped), - operations: MemoryNotice.ops({ ops: accepted, skipped: result.skipped }), - files: MemoryShared.files(accepted), - summary: MemoryNotice.summary({ added: result.added, removed: result.removed, count: result.operationCount }), - }) - } return { root: input.root, state, @@ -318,31 +288,6 @@ export namespace Memory { const hits = result?.hits ?? [] const files = [...new Set(hits.map((hit) => hit.source))] const topics = [...new Set(hits.flatMap((hit) => (hit.topics?.length ? hit.topics : [hit.kind])))] - await MemoryFiles.decide(input.root, { - kind: "recall", - trigger: "targeted-recall", - sessionID: input.sessionID, - result: result ? "recalled" : "skipped", - llm: false, - parsed: false, - fallback: false, - reason: result ? undefined : "no_matches", - query: MemoryShared.brief(MemoryRedact.text(input.query), 240), - topics, - files, - tokens: result?.tokens ?? 0, - operationCount: hits.length, - skippedCount: result ? 0 : 1, - summary: result ? `targeted recall matched ${hits.length} memories` : "targeted recall found no matches", - }) - if (result) { - await MemoryFiles.queue(input.root, async () => { - await MemoryFiles.append( - input.root, - `recall session=${input.sessionID ?? ""} hits=${result.hits.length} tokens=${result.tokens} files=${files.join(",")}`, - ) - }) - } return { root: input.root, state, result, hits, files, topics } } @@ -368,10 +313,6 @@ export namespace Memory { }) await MemoryFiles.pruneSessions(input.root, state.limits.maxSessionFiles) const index = await MemoryIndexer.rebuild({ root: input.root, state }) - await MemoryFiles.append( - input.root, - `session digest session=${input.sessionID} tokens=${input.tokens ?? 0} indexTokens=${index.tokens}`, - ) return { root: input.root, state, skipped: false as const, index } }) } diff --git a/packages/kilo-memory/src/recall/indexer.ts b/packages/kilo-memory/src/recall/indexer.ts index e4c219cbb01..1f906d2867b 100644 --- a/packages/kilo-memory/src/recall/indexer.ts +++ b/packages/kilo-memory/src/recall/indexer.ts @@ -226,7 +226,6 @@ export namespace MemoryIndexer { return MemoryFiles.queue(input.root, async () => { const result = await build(input) await MemoryFiles.writeIndex(input.root, result.text) - await MemoryFiles.append(input.root, `regenerate index.kmem bytes=${result.bytes} tokens=${result.tokens}`) return result }) } diff --git a/packages/kilo-memory/src/recall/shared.ts b/packages/kilo-memory/src/recall/shared.ts index b24829b2f46..b38f215e6ac 100644 --- a/packages/kilo-memory/src/recall/shared.ts +++ b/packages/kilo-memory/src/recall/shared.ts @@ -94,6 +94,7 @@ export namespace MemoryShared { ] } + /** @deprecated Memory audit persistence was removed. */ export function audit(ops: MemoryOperations.Op[]) { return ops.map((item) => item.action === "add" diff --git a/packages/kilo-memory/src/schema.ts b/packages/kilo-memory/src/schema.ts index 59436a8c144..0ca36e7616e 100644 --- a/packages/kilo-memory/src/schema.ts +++ b/packages/kilo-memory/src/schema.ts @@ -1,3 +1,9 @@ +/** Wire contract for suppressible background-capture failures: guardReason emits this as the + * `memory.error` reason, and every client surface (TUI toast filter, VS Code provider) matches + * against this same constant rather than a local literal. Lives here (dependency-free module) + * so clients can import it without pulling the capture pipeline into their bundles. */ +export const TRANSIENT = "transient" as const + export namespace MemorySchema { export const VERSION = 1 export const maxStoredDigestSummary = 4_000 diff --git a/packages/kilo-memory/src/storage/audit.ts b/packages/kilo-memory/src/storage/audit.ts index 70c7c42bf3a..58fe9f496b7 100644 --- a/packages/kilo-memory/src/storage/audit.ts +++ b/packages/kilo-memory/src/storage/audit.ts @@ -1,15 +1,5 @@ -import z from "zod" -import { MemoryFs } from "./fs" - +/** Compatibility facade for the removed memory audit log. */ export namespace MemoryAudit { - const Log = z - .object({ - kind: z.literal("log"), - summary: z.string(), - time: z.string().optional(), - }) - .passthrough() - export type Decision = | { kind: "log" @@ -43,18 +33,14 @@ export namespace MemoryAudit { }[] } - function audit(root: string, input: Decision) { - void root - void input - return Promise.resolve() - } - export async function append(root: string, text: string) { - await audit(root, { kind: "log", result: "logged", summary: text }) + void root + void text } export async function decide(root: string, input: Decision) { - await audit(root, input) + void root + void input } export async function readDecisions(root: string) { @@ -62,24 +48,8 @@ export namespace MemoryAudit { return "" } - function record(input: string) { - try { - const data = JSON.parse(input) - const parsed = Log.safeParse(data) - return parsed.success ? parsed.data : undefined - } catch (error) { - if (MemoryFs.parse(error)) return undefined - throw error - } - } - export async function readChanges(root: string) { - const lines = (await readDecisions(root)).split("\n").flatMap((line) => { - const data = record(line) - if (!data) return [] - const time = data.time ?? "" - return [`${time} ${data.summary}`.trim()] - }) - return lines.join("\n") + void root + return "" } } diff --git a/packages/kilo-memory/src/storage/state.ts b/packages/kilo-memory/src/storage/state.ts index bb373e24610..136a83a3e5e 100644 --- a/packages/kilo-memory/src/storage/state.ts +++ b/packages/kilo-memory/src/storage/state.ts @@ -1,6 +1,5 @@ import { readdir, rm } from "fs/promises" import path from "path" -import { MemoryAudit } from "./audit" import { MemoryFs } from "./fs" import { MemoryMarkdown } from "./markdown" import { MemoryPaths } from "./paths" @@ -19,13 +18,10 @@ export namespace MemoryState { "corrections.md": "# Corrective Memory\n\n## Corrections\n", } - async function recover(root: string, file: string, error: unknown) { + async function recover(root: string, file: string) { await MemoryFs.backup(file) const state = MemorySchema.missing() await writeState(root, state) - await MemoryAudit.append(root, `recover state.json error=${MemoryFs.brief(error)}`).catch((err: unknown) => - MemoryFs.warn("failed to audit memory state recovery", { err, root }), - ) return state } @@ -33,14 +29,14 @@ export namespace MemoryState { const file = MemoryPaths.files(root).state const data = await MemoryFs.json(file).catch(async (error: unknown) => { if (MemoryFs.miss(error)) return undefined - if (MemoryFs.parse(error)) return recover(root, file, error) + if (MemoryFs.parse(error)) return recover(root, file) throw error }) if (data === undefined) return MemorySchema.missing() return Promise.resolve() .then(() => MemorySchema.parse(data)) .catch((error: unknown) => { - if (MemoryFs.parse(error)) return recover(root, file, error) + if (MemoryFs.parse(error)) return recover(root, file) throw error }) } @@ -199,7 +195,6 @@ export namespace MemoryState { ? { ...(await readState(root)), enabled: true, autoInject: true } : { ...MemorySchema.create(), enabled: true } await writeState(root, state) - await MemoryAudit.append(root, "enable project source=command") return state } diff --git a/packages/kilo-memory/src/tool.ts b/packages/kilo-memory/src/tool.ts index e87fb85d0f5..cef71179d35 100644 --- a/packages/kilo-memory/src/tool.ts +++ b/packages/kilo-memory/src/tool.ts @@ -4,7 +4,6 @@ import { MemoryError, type MemoryError as Failure } from "./effect/errors" import { MemoryPaths } from "./effect/paths" import { MemoryService } from "./effect/service" import { MemoryRecall } from "./recall/recall" -import { MemoryToken } from "./recall/token" import { MemorySchema } from "./schema" import recallDescription from "./prompts/tool-memory-recall.txt" import saveDescription from "./prompts/tool-memory-save.txt" @@ -115,50 +114,6 @@ export namespace MemoryTool { } } - function audit( - memory: MemoryService.Interface, - input: { - root: string - params: RecallParams - current: string - hits: MemoryRecall.Hit[] - skipped?: string - output: string - }, - ) { - const files = [...new Set(input.hits.map((hit) => hit.source))] - const topics = [...new Set(input.hits.flatMap((hit) => (hit.topics?.length ? hit.topics : [hit.kind])))] - const query = - input.params.query ?? - (input.params.sessionID - ? `sessionID=${input.params.sessionID}` - : input.params.mode === "digest" - ? "recent digests" - : undefined) - return memory.decide({ - root: input.root, - decision: { - kind: "recall", - trigger: "targeted-recall", - sessionID: input.current, - result: input.hits.length ? "recalled" : "skipped", - llm: false, - parsed: false, - fallback: false, - reason: input.skipped, - query, - topics, - files, - tokens: MemoryToken.estimate(input.output), - operationCount: input.hits.length, - skippedCount: input.hits.length ? 0 : 1, - summary: input.hits.length - ? `memory recall returned ${input.hits.length} ${input.params.mode} hits` - : `memory recall found no ${input.params.mode} hits`, - }, - }) - } - function miss(input: { params: RecallParams; current: string }) { const self = input.params.sessionID === input.current if (self && input.params.mode === "digest") { @@ -239,35 +194,6 @@ export namespace MemoryTool { }) } - function catalogAudit( - memory: MemoryService.Interface, - input: { - root: string - current: string - query: string - result: { output: string; count: number; files: string[] } - }, - ) { - return memory.decide({ - root: input.root, - decision: { - kind: "recall", - trigger: "targeted-recall", - sessionID: input.current, - result: input.result.count ? "recalled" : "skipped", - llm: false, - parsed: false, - fallback: false, - query: input.query || "all keys", - files: input.result.files, - tokens: MemoryToken.estimate(input.result.output), - operationCount: input.result.count, - skippedCount: input.result.count ? 0 : 1, - summary: `memory catalog listed ${input.result.count} entries`, - }, - }) - } - function approvalRecall(input: Recall) { return input.ask({ permission: "kilo_memory_recall", @@ -285,7 +211,6 @@ export namespace MemoryTool { return Effect.gen(function* () { const result = yield* catalog(input.memory, { root: live.root, query }) const safe = { ...result, output: block(result.output) } - yield* catalogAudit(input.memory, { root: live.root, current: live.current, query, result: safe }) yield* input.memory.recordRecall({ root: live.root, sessionID: live.current, now: Date.now(), count: result.count }) return { title: `Kilo memory catalog: ${result.count} entr${result.count === 1 ? "y" : "ies"}`, @@ -296,22 +221,13 @@ export namespace MemoryTool { } function recallQuery(input: Recall, live: Live) { - return Effect.gen(function* () { - const output = "Provide a topic query for typed/search memory recall." - yield* audit(input.memory, { - root: live.root, - params: input.params, - current: live.current, - hits: [], - skipped: "missing_query", - output, - }) - return { + return Effect.succeed( + { title: `Kilo memory ${input.params.mode}: no query`, - output, + output: "Provide a topic query for typed/search memory recall.", metadata: { sources: [], count: 0 }, - } satisfies Result - }) + } satisfies Result, + ) } function recallSearch(input: Recall, live: Live, query: string, mode: MemoryRecall.Mode) { @@ -327,15 +243,7 @@ export namespace MemoryTool { limit, }) const hits = result?.hits ?? [] - const self = input.params.sessionID === live.current - const skipped = - input.params.sessionID && input.params.mode === "digest" && hits.length === 0 - ? self - ? "current_session_digest" - : "missing_session_digest" - : undefined const output = hits.length ? result!.block : miss({ params: input.params, current: live.current }) - yield* audit(input.memory, { root: live.root, params: input.params, current: live.current, hits, skipped, output }) yield* input.memory.recordRecall({ root: live.root, sessionID: live.current, now: Date.now(), count: hits.length }) if (hits.length === 0) { @@ -440,29 +348,8 @@ export namespace MemoryTool { return saved(input) } - function skip(input: Base & { params: SaveParams }, root: string) { - return Effect.gen(function* () { - const reason = input.params.reason ?? "out_of_scope" - yield* input.memory.decide({ - root, - decision: { - kind: "typed", - trigger: "explicit", - sessionID: input.sessionID, - result: "skipped", - llm: false, - parsed: true, - fallback: false, - reason, - tokens: 0, - operationCount: 0, - skippedCount: 1, - skipped: [{ reason }], - summary: `explicit memory save skipped: ${reason}`, - }, - }) - return skipped({ reason }) - }) + function skip(input: Base & { params: SaveParams }) { + return Effect.succeed(skipped({ reason: input.params.reason ?? "out_of_scope" })) } function forget(input: Save, root: string) { @@ -497,7 +384,7 @@ export namespace MemoryTool { const state = yield* input.memory.state({ root }) if (!state.enabled) return disabled() if (input.params.action === "forget") return yield* forget(input, root) - if (input.params.action === "skip") return yield* skip(input, root) + if (input.params.action === "skip") return yield* skip(input) return yield* write(input, root) }) } diff --git a/packages/kilo-memory/test/capture.test.ts b/packages/kilo-memory/test/capture.test.ts index a1dcdc609c2..516ed3a65d3 100644 --- a/packages/kilo-memory/test/capture.test.ts +++ b/packages/kilo-memory/test/capture.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test" import { - auditOps, capturePlan, duplicateOps, + errorReason, fallbackDigest, guardReason, hasSubstantialDiff, @@ -128,7 +128,7 @@ describe("memory capture parsing", () => { expect(() => salvageTyped(`{"op":"upsert_project_fact","key":"good_one","value":"Keep this fact."}`)).toThrow() }) - test("redacts secrets in salvaged unsupported ops before they reach the audit", () => { + test("redacts secrets in salvaged unsupported ops before they reach callers", () => { const parsed = salvageTyped( `{"operations":[{"op":"not_a_real_op","key":"leak","value":"key is sk-abcdefghijklmnopqrstuvwxyz"}],"skipped":[]}`, ) @@ -169,17 +169,6 @@ describe("memory capture parsing", () => { expect(JSON.stringify(parsed.skipped)).not.toContain(secret.slice(0, 20)) }) - test("redacts remove audit queries before truncating", () => { - const secret = "sk-" + "a".repeat(40) - const query = "x".repeat(100) + secret - const audit = auditOps([{ action: "remove", query }]) - const text = JSON.stringify(audit) - - expect(text).toContain("[redacted]") - expect(text).not.toContain(secret) - expect(text).not.toContain(secret.slice(0, 20)) - }) - test("truncates typed batches beyond the op cap instead of failing", () => { const ops = Array.from( { length: 20 }, @@ -332,6 +321,11 @@ describe("memory capture parsing", () => { input: base, expected: { session: true, digestDue: true, typedCall: true, typedWork: true, skipReason: undefined }, }, + { + name: "expected work: a persisted null typed clock does not throttle capture", + input: { ...base, lastTypedConsolidationAt: null }, + expected: { session: true, digestDue: true, typedCall: true, typedWork: true, skipReason: undefined }, + }, { name: "expected idle flush: completed turn inside interval skips now", input: { ...base, priorTime: 900, lastTypedConsolidationAt: 900 }, @@ -650,6 +644,70 @@ describe("memory capture parsing", () => { ) expect(guardReason("429 too many requests")).toBe("rate_limit_guard") expect(guardReason("billing credits exhausted")).toBe("quota_guard") + expect(guardReason("request timed out")).toBe("transient") + expect(guardReason("deadline exceeded")).toBe("transient") + expect(guardReason("DeadlineExceeded")).toBe("transient") + expect(guardReason('cause={"code":"ETIMEDOUT"}')).toBe("transient") + expect(guardReason("504 Gateway Timeout")).toBe("transient") + expect(guardReason("Connect Timeout Error")).toBe("transient") + expect(guardReason("Headers Timeout Error")).toBe("transient") + expect(guardReason("Body Timeout Error")).toBe("transient") + expect(guardReason("connect_timeout")).toBe("transient") + expect(guardReason('status=400 body={"error":"invalid parameter: timeout"}')).toBeUndefined() + expect(guardReason("set request timeout to 30000")).toBeUndefined() + const timeout = new Error("request aborted") + timeout.name = "TimeoutError" + expect(guardReason(timeout)).toBe("transient") + expect(errorReason(timeout)).toBe("request aborted") + expect(guardReason(Object.assign(new Error("request failed"), { status: 504 }))).toBe("transient") + expect( + guardReason(Object.assign(new Error("request failed"), { cause: { code: "UND_ERR_CONNECT_TIMEOUT" } })), + ).toBe("transient") + expect( + guardReason(Object.assign(new Error("request failed"), { cause: { code: "UND_ERR_HEADERS_TIMEOUT" } })), + ).toBe("transient") + expect(guardReason(Object.assign(new Error("request failed"), { cause: { code: "ETIMEDOUT" } }))).toBe( + "transient", + ) + expect( + guardReason(Object.assign(new Error("failed after 2 attempts"), { errors: [{ statusCode: 504 }] })), + ).toBe("transient") + expect(guardReason(new Error("set request timeout to 30000"))).toBeUndefined() + expect(guardReason(new Error("request timed out"))).toBe("transient") + expect(guardReason(Object.assign(new Error("request failed"), { cause: new Error("connect timeout") }))).toBe( + "transient", + ) + expect( + guardReason( + Object.assign(new Error("failed after 2 attempts"), { + errors: [Object.assign(new Error("rate limit reached"), { status: 429 }), timeout], + }), + ), + ).toBe("rate_limit_guard") + expect( + guardReason( + Object.assign(new Error("failed after 2 attempts"), { + errors: [new Error("insufficient quota available"), timeout], + }), + ), + ).toBe("quota_guard") + expect( + guardReason( + Object.assign(new Error("request failed"), { + cause: Object.assign(new Error("rate limit reached"), { status: 429 }), + }), + ), + ).toBe("rate_limit_guard") + expect( + guardReason( + Object.assign(new Error("timed out"), { + name: "TimeoutError", + cause: new Error("billing credit balance exhausted"), + }), + ), + ).toBe("quota_guard") + expect(guardReason("timeout after 429 too many requests")).toBe("rate_limit_guard") + expect(guardReason("timeout after quota exceeded")).toBe("quota_guard") }) test("redacts common secret token shapes", () => { diff --git a/packages/kilo-memory/test/core.test.ts b/packages/kilo-memory/test/core.test.ts index 04b52a03624..9c029e6e211 100644 --- a/packages/kilo-memory/test/core.test.ts +++ b/packages/kilo-memory/test/core.test.ts @@ -50,6 +50,22 @@ describe("memory core package", () => { }) }) + test("legacy audit compatibility APIs remain no-op", async () => { + await use(async (t) => { + await Memory.enable({ root: t.root }) + await MemoryFiles.append(t.root, "provider error with sensitive detail") + await MemoryFiles.decide(t.root, { + kind: "typed", + result: "error", + reason: "provider error with sensitive detail", + }) + + expect(await MemoryFiles.readChanges(t.root)).toBe("") + expect(await MemoryFiles.readDecisions(t.root)).toBe("") + expect(await Bun.file(path.join(t.root, "decisions.jsonl")).exists()).toBe(false) + }) + }) + test("prepare removes legacy decisions once from owned memory roots", async () => { await use(async (t) => { await Memory.enable({ root: t.root }) diff --git a/packages/kilo-memory/test/effect-capture.test.ts b/packages/kilo-memory/test/effect-capture.test.ts index 91e0ec84e04..50a86b0f1f4 100644 --- a/packages/kilo-memory/test/effect-capture.test.ts +++ b/packages/kilo-memory/test/effect-capture.test.ts @@ -7,6 +7,7 @@ import { digestPrompt, typedPrompt } from "../src/capture/capture" import { MemoryCapture } from "../src/effect/capture" import { MemoryEvents } from "../src/effect/events" import { KiloMemory } from "../src/effect/index" +import { MemoryLog } from "../src/effect/log" import type { MemoryPorts } from "../src/effect/ports" import { MemoryService } from "../src/effect/service" import { MemoryTimers } from "../src/effect/timers" @@ -49,11 +50,18 @@ function session(turn: MemoryPorts.TurnView | undefined): MemoryPorts.SessionPor /** Model port that answers digest/typed calls from canned JSON, keyed by system prompt so it is * order-independent (digest and typed run concurrently). */ -function model(input: { digest: string; typed: string; fallback?: string; onRun?: (system: string) => void }): MemoryPorts.ModelPort { +function model(input: { + digest: string + typed: string + fallback?: string + fail?: Error + onRun?: (system: string) => void +}): MemoryPorts.ModelPort { return { resolve: () => Effect.succeed({ handle: {}, ...(input.fallback ? { fallback: { reason: input.fallback } } : {}) }), run: async ({ system }) => { input.onRun?.(system) + if (system === typedPrompt && input.fail) throw input.fail const text = system === digestPrompt ? input.digest : system === typedPrompt ? input.typed : "{}" return { text, usage: USAGE } }, @@ -66,6 +74,7 @@ function run(input: { model: MemoryPorts.ModelPort memoryModel?: string reason?: "completed" | "interrupted" | "error" + bypassInterval?: boolean }) { return Effect.runPromise( MemoryCapture.turn({ @@ -75,6 +84,7 @@ function run(input: { model: input.model, memoryModel: input.memoryModel, reason: input.reason ?? "completed", + bypassInterval: input.bypassInterval, }).pipe(Effect.provideService(MemoryService.Service, MemoryService.make())), ) } @@ -107,6 +117,121 @@ describe("MemoryCapture (fake ports)", () => { } }) + test("typed timeout preserves digest progress without advancing the typed clock", async () => { + const t = await tmp() + const events: MemoryEvents.Status[] = [] + const logs: string[] = [] + try { + await KiloMemory.enable({ root: t.root }) + await KiloMemory.configure({ root: t.root, settings: { autoConsolidate: true } }) + MemoryLog.setWarn((message, meta) => logs.push(`${message}:${meta?.reason}:${meta?.detail}`)) + MemoryEvents.setSink((input) => { + events.push(input.payload) + }) + + const result = await run({ + root: t.root, + session: session(view()), + model: model({ + digest: '{"topic":"repo setup","summary":"Explored repo setup commands. Next step: verify memory tests."}', + typed: "{}", + fail: new DOMException("memory model timed out", "TimeoutError"), + }), + }) + + expect(result).toMatchObject({ skipped: false, operationCount: 0 }) + const state = await MemoryFiles.readState(t.root) + expect(state.stats.lastTypedConsolidationAt).toBeNull() + expect(state.stats.lastSessionSavedAt).toEqual(expect.any(Number)) + expect(state.stats.lastConsolidatedMessageID).toBe("msg_assistant") + expect(events.find((item) => item.state === "error")?.reason).toBe("transient") + expect(logs).toEqual(["memory capture transient failure:transient:memory model timed out"]) + + const calls: string[] = [] + const retry = await run({ + root: t.root, + session: session(view()), + model: model({ + digest: "{}", + typed: '{"operations":[],"skipped":[]}', + onRun: (system) => calls.push(system), + }), + bypassInterval: true, + }) + expect(retry).toMatchObject({ skipped: true, reason: "no_new_content" }) + expect(calls).toEqual([]) + + const next = await run({ + root: t.root, + session: session( + view({ + assistant: "Use bun install, then run the package tests and typecheck.", + lastAssistantID: "msg_assistant_next", + }), + ), + model: model({ + digest: "{}", + typed: '{"operations":[],"skipped":[]}', + onRun: (system) => calls.push(system), + }), + }) + expect(next).toMatchObject({ skipped: false, operationCount: 0 }) + expect(calls).toEqual([typedPrompt]) + const updated = await MemoryFiles.readState(t.root) + expect(updated.stats.lastTypedConsolidationAt).toEqual(expect.any(Number)) + expect(updated.stats.lastConsolidatedMessageID).toBe("msg_assistant_next") + } finally { + MemoryLog.setWarn(() => {}) + MemoryEvents.setSink(() => {}) + await t.done() + } + }) + + test("fallback commit preserves metrics from the prior successful typed consolidation", async () => { + const t = await tmp() + try { + await KiloMemory.enable({ root: t.root }) + await KiloMemory.configure({ root: t.root, settings: { autoConsolidate: true } }) + + const first = await run({ + root: t.root, + session: session(view()), + model: model({ + digest: '{"topic":"repo setup","summary":"Explored repo setup."}', + typed: + '{"operations":[{"op":"upsert_environment_fact","section":"Commands","key":"test_cmd","value":"bun test"}],"skipped":[]}', + }), + }) + expect(first).toMatchObject({ skipped: false, operationCount: 1 }) + const initial = await MemoryFiles.readState(t.root) + expect(initial.stats.lastOperationCount).toBe(1) + expect(initial.stats.lastTypedConsolidationAt).toEqual(expect.any(Number)) + + const second = await run({ + root: t.root, + session: session( + view({ + assistant: "Investigated a timeout edge case.", + lastAssistantID: "msg_assistant_timeout", + }), + ), + model: model({ + digest: '{"topic":"investigation","summary":"Investigated timeouts."}', + typed: "{}", + fail: new DOMException("memory model timed out", "TimeoutError"), + }), + bypassInterval: true, + }) + expect(second).toMatchObject({ skipped: false, operationCount: 0 }) + const preserved = await MemoryFiles.readState(t.root) + expect(preserved.stats.lastOperationCount).toBe(1) + expect(preserved.stats.lastTypedConsolidationAt).toBe(initial.stats.lastTypedConsolidationAt) + expect(preserved.stats.lastConsolidatedMessageID).toBe("msg_assistant_timeout") + } finally { + await t.done() + } + }) + test("turn-close skips a secret-like op and applies the rest of the batch", async () => { const t = await tmp() const events: MemoryEvents.Status[] = [] diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 4660904c00a..918b0a0afc3 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1,6 +1,7 @@ import * as path from "path" import { existsSync } from "fs" import * as vscode from "vscode" +import { TRANSIENT as MEMORY_TRANSIENT } from "@kilocode/kilo-memory/schema" import type { KiloClient, Session, @@ -4307,8 +4308,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper for (const sid of trackedByDir) targets.add(sid) if (local && active) targets.add(active) if (targets.size === 0 && local) targets.add(undefined) - const detail = - props.detail && typeof props.detail === "object" + const transient = event.type === "memory.error" && props.reason === MEMORY_TRANSIENT + const detail = transient + ? undefined + : props.detail && typeof props.detail === "object" ? props.detail : event.type === "memory.error" && typeof props.reason === "string" ? { type: "error", message: props.reason, reason: props.reason } diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts index 274cb8cc60d..815ab32f8bd 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-memory-events.test.ts @@ -127,6 +127,43 @@ describe("KiloProvider memory events", () => { expect(calls).toEqual(["/repo", "/repo"]) }) + it("refreshes status without forwarding transient memory errors", async () => { + const calls: string[] = [] + const posts: unknown[] = [] + const client = { + memory: { + status: async (input: { directory: string }) => { + calls.push(input.directory) + return { data: status(input.directory) } + }, + }, + } as unknown as KiloClient + const provider = new KiloProvider( + {} as never, + { + getClient: () => client, + } as never, + ) + const item = provider as unknown as Internals + item.webview = { postMessage: async (message) => posts.push(message) } + item.currentSession = { id: "ses_active" } + item.trackedSessionIds.add("ses_active") + provider.setSessionDirectory("ses_active", "/repo") + + item.handleEvent( + { + type: "memory.error", + properties: { sessionID: "ses_active", reason: "transient" }, + }, + "/repo", + ) + await item.memory.idle() + + expect(posts).not.toContainEqual(expect.objectContaining({ type: "memoryEvent" })) + expect(posts).toContainEqual(expect.objectContaining({ type: "memoryLoaded", sessionID: "ses_active" })) + expect(calls).toEqual(["/repo"]) + }) + it("uses the project directory when toggling memory", async () => { const calls: unknown[] = [] const client = { diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/memory-events.ts b/packages/opencode/src/kilocode/cli/cmd/tui/memory-events.ts index c388231b922..293feee20cf 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/memory-events.ts +++ b/packages/opencode/src/kilocode/cli/cmd/tui/memory-events.ts @@ -1,3 +1,5 @@ +import { TRANSIENT } from "@kilocode/kilo-memory/schema" + type Event = { on(type: "memory.error", fn: (event: MemoryEvent) => void): void | (() => void) } @@ -22,6 +24,7 @@ export namespace MemoryTuiEvents { }) { const handler = (event: MemoryEvent) => { if (event.properties.sessionID && event.properties.sessionID !== input.sessionID) return + if (event.properties.reason === TRANSIENT) return const detail = event.properties.detail if (!detail || typeof detail !== "object") { input.toast.show({ diff --git a/packages/opencode/src/kilocode/memory/ports.ts b/packages/opencode/src/kilocode/memory/ports.ts index 03d26d54674..9b38ceac89d 100644 --- a/packages/opencode/src/kilocode/memory/ports.ts +++ b/packages/opencode/src/kilocode/memory/ports.ts @@ -191,6 +191,7 @@ async function memoryText(input: { temperature: input.temperature, topP: input.topP, topK: input.topK, + maxRetries: 1, } const work = async () => { if (!openai) return generateText(common) @@ -210,7 +211,7 @@ async function memoryText(input: { const timeout = new Promise((_, reject) => { timer = setTimeout(() => { ctl.abort() - reject(new Error("memory model timed out")) + reject(new DOMException("memory model timed out", "TimeoutError")) }, ms) }) try { diff --git a/packages/opencode/test/kilocode/cli/cmd/tui/memory-command.test.ts b/packages/opencode/test/kilocode/cli/cmd/tui/memory-command.test.ts index 42df5344333..7448077955b 100644 --- a/packages/opencode/test/kilocode/cli/cmd/tui/memory-command.test.ts +++ b/packages/opencode/test/kilocode/cli/cmd/tui/memory-command.test.ts @@ -276,7 +276,7 @@ describe("memory TUI events", () => { expect(handlers).toEqual({ "memory.error": [expect.any(Function)] }) }) - test("keeps generic and detailed errors visible", async () => { + test("suppresses transient errors while keeping generic and detailed errors visible", async () => { const shown: string[] = [] const handlers: Record = {} MemoryTuiEvents.attach({ @@ -298,6 +298,17 @@ describe("memory TUI events", () => { fn({ properties: { sessionID: "ses_tui_memory", reason: "model failed" } }), ), ) + await Promise.all( + (handlers["memory.error"] ?? []).map((fn) => + fn({ + properties: { + sessionID: "ses_tui_memory", + reason: "transient", + detail: { message: "Memory model timed out" }, + }, + }), + ), + ) await Promise.all( (handlers["memory.error"] ?? []).map((fn) => fn({ properties: { sessionID: "ses_tui_memory", detail: { message: "Memory save failed" } } }), diff --git a/packages/opencode/test/kilocode/memory/memory-ports.test.ts b/packages/opencode/test/kilocode/memory/memory-ports.test.ts index 8016c1ab00f..8b39e8e99c0 100644 --- a/packages/opencode/test/kilocode/memory/memory-ports.test.ts +++ b/packages/opencode/test/kilocode/memory/memory-ports.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" +import { APICallError } from "ai" import { Effect } from "effect" import { ModelNotFoundError, type Provider } from "../../../src/provider/provider" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -31,15 +32,22 @@ function mdl(id = mid): Provider.Model { } as unknown as Provider.Model } -function lang(outputs = ["{}"]): LanguageModelV3 { +function lang(outputs: (string | Error)[] = ["{}"], calls?: unknown[], hang?: boolean): LanguageModelV3 { let idx = 0 + const next = () => { + const item = outputs[idx++] ?? outputs.at(-1) ?? "{}" + if (item instanceof Error) throw item + return item + } return { specificationVersion: "v3", provider: "test", modelId: "fake-memory-model", supportedUrls: {}, - doGenerate: async () => { - const text = outputs[idx++] ?? outputs.at(-1) ?? "{}" + doGenerate: async (...args: Parameters) => { + calls?.push(args[0]) + if (hang) return new Promise(() => {}) + const text = next() return { content: [{ type: "text", text }], finishReason: { unified: "stop" }, @@ -57,7 +65,9 @@ function lang(outputs = ["{}"]): LanguageModelV3 { } as unknown as LanguageModelV3 } -function provider(input: { outputs?: string[]; seen?: string[] } = {}): Provider.Interface { +function provider( + input: { outputs?: (string | Error)[]; seen?: string[]; calls?: unknown[]; hang?: boolean } = {}, +): Provider.Interface { const base = mdl() const mem = mdl(ModelV2.ID.make("memory-config-model")) const info = { @@ -78,7 +88,7 @@ function provider(input: { outputs?: string[]; seen?: string[] } = {}): Provider }, getLanguage: (model) => { input.seen?.push(model.id) - return Effect.succeed(lang(input.outputs)) + return Effect.succeed(lang(input.outputs, input.calls, input.hang)) }, closest: () => Effect.succeed({ providerID: pid, modelID: base.id }), getSmallModel: () => Effect.succeed(mem), @@ -280,6 +290,34 @@ describe("memory ports", () => { expect(seen).toEqual(["memory-config-model", "fake-memory-model"]) }) + test("model port retries a transient provider failure once", async () => { + const calls: unknown[] = [] + const err = new APICallError({ + message: "temporarily unavailable", + url: "https://example.com/v1/generate", + requestBodyValues: {}, + statusCode: 503, + responseHeaders: {}, + responseBody: '{"error":"temporarily unavailable"}', + isRetryable: true, + }) + const port = MemoryModel.port({ provider: provider({ outputs: [err, "{}"], calls }) }) + const resolved = await Effect.runPromise(port.resolve({ session: ref })) + + await port.run({ handle: resolved.handle, system: "system", prompt: "prompt", timeoutMs: 30_000 }) + + expect(calls).toHaveLength(2) + }) + + test("model port emits a structured timeout error", async () => { + const port = MemoryModel.port({ provider: provider({ hang: true }) }) + const resolved = await Effect.runPromise(port.resolve({ session: ref })) + + await expect( + port.run({ handle: resolved.handle, system: "system", prompt: "prompt", timeoutMs: 1 }), + ).rejects.toMatchObject({ name: "TimeoutError", message: "memory model timed out" }) + }) + test("model port clears its timeout after successful output", async () => { const set = globalThis.setTimeout const clear = globalThis.clearTimeout