From 5d713f7d9602db301452292d20f83b87c22f6a30 Mon Sep 17 00:00:00 2001 From: Thomas Brugman Date: Sun, 26 Jul 2026 19:57:38 +0200 Subject: [PATCH 1/3] fix(vscode): persist installed agent removal --- .changeset/clean-agent-removal.md | 5 ++ packages/kilo-vscode/src/KiloProvider.ts | 18 +++--- .../kilo-vscode/src/services/agent-removal.ts | 29 ++++++++++ .../src/services/marketplace/actions.ts | 6 ++ .../unit/agent-behaviour-patches.test.ts | 10 ++++ .../tests/unit/marketplace-actions.test.ts | 44 +++++++++++++++ .../components/settings/AgentBehaviourTab.tsx | 5 +- .../src/components/settings/ModeEditView.tsx | 21 ++++--- .../settings/agent-behaviour-patches.ts | 6 ++ .../webview-ui/src/types/messages/agents.ts | 1 + packages/opencode/src/kilocode/agent/index.ts | 28 +++++++--- .../server/httpapi/handlers/kilocode.ts | 11 +++- .../test/kilocode/agent-remove.test.ts | 55 ++++++++++++++++++- .../server/httpapi-exercise-scenarios.ts | 31 +++++++++++ 14 files changed, 241 insertions(+), 29 deletions(-) create mode 100644 .changeset/clean-agent-removal.md create mode 100644 packages/kilo-vscode/src/services/agent-removal.ts diff --git a/.changeset/clean-agent-removal.md b/.changeset/clean-agent-removal.md new file mode 100644 index 00000000000..c1639c43f8a --- /dev/null +++ b/.changeset/clean-agent-removal.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Remove installed agents from every writable configuration source and report removal failures. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 72f6f4378f3..4d40e25c518 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -57,6 +57,7 @@ import { resolveProjectDirectory } from "./project-directory" import { seedSessionStatuses } from "./session-status" import { normalizeEnhancePromptErrorMessage } from "./enhance-prompt-error" import { retry } from "./services/cli-backend/retry" +import { removeAgent } from "./services/agent-removal" import { normalize, type SSEPayload, type SyncPayload, type WirePayload } from "./services/cli-backend/sdk-sse-adapter" import { slimInfo, slimPart, slimParts } from "./kilo-provider/slim-metadata" import { handleSidebarWorktreeMessage } from "./kilo-provider/sidebar-worktree" @@ -199,6 +200,7 @@ function sandboxClient(client: KiloClient | null) { const mapAgent = (a: Agent) => ({ name: a.name, displayName: a.displayName, + source: a.source, description: a.description, mode: a.mode, native: a.native, @@ -2481,14 +2483,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper /** Remove an agent via the CLI backend, then refresh. */ private async handleRemoveAgent(name: string): Promise { - if (!this.client) return - try { - const result = await this.client.kilocode.removeAgent({ name, directory: this.getWorkspaceDirectory() }) - if (result.error) { - console.error("[Kilo New] removeAgent returned error:", result.error) - } - } catch (err) { - console.error("[Kilo New] Failed to remove agent:", err) + const result = await removeAgent({ + connection: this.connectionService, + directory: this.getWorkspaceDirectory(), + name, + }) + if (!result.success) { + console.error("[Kilo New] Failed to remove agent:", result.error) + void vscode.window.showErrorMessage(result.error ?? `Failed to remove agent "${name}".`) } this.cachedAgentsMessage = null await this.fetchAndSendAgents() diff --git a/packages/kilo-vscode/src/services/agent-removal.ts b/packages/kilo-vscode/src/services/agent-removal.ts new file mode 100644 index 00000000000..783543cf273 --- /dev/null +++ b/packages/kilo-vscode/src/services/agent-removal.ts @@ -0,0 +1,29 @@ +import { getErrorMessage } from "../kilo-provider-utils" +import type { KiloConnectionService } from "./cli-backend" +import type { RemoveResult } from "./marketplace/types" + +interface Input { + connection: KiloConnectionService + directory: string + name: string +} + +export async function removeAgent(input: Input): Promise { + try { + const client = await input.connection.getClientAsync(input.directory) + const result = await client.kilocode.removeAgent({ name: input.name, directory: input.directory }) + if (!result.error) return { success: true, slug: input.name } + + return { + success: false, + slug: input.name, + error: getErrorMessage(result.error) ?? `Agent "${input.name}" is still provided by another configuration.`, + } + } catch (err) { + return { + success: false, + slug: input.name, + error: getErrorMessage(err) ?? `Failed to remove agent "${input.name}".`, + } + } +} diff --git a/packages/kilo-vscode/src/services/marketplace/actions.ts b/packages/kilo-vscode/src/services/marketplace/actions.ts index 9237d54ecac..47cefe38b98 100644 --- a/packages/kilo-vscode/src/services/marketplace/actions.ts +++ b/packages/kilo-vscode/src/services/marketplace/actions.ts @@ -2,6 +2,7 @@ import * as path from "path" import * as vscode from "vscode" import type { KiloConnectionService } from "../cli-backend" import { retry } from "../cli-backend/retry" +import { removeAgent } from "../agent-removal" import type { MarketplaceService } from "." import type { InstallMarketplaceItemOptions, @@ -67,6 +68,11 @@ export async function removeMarketplaceItem( } try { + if (item.type === "agent") { + const result = await removeAgent({ connection: ctx.connection, directory: dir, name: item.id }) + if (result.success) await invalidate(ctx, scope, dir) + return result + } if (item.type === "mcp") await removeLegacyMcp(ctx, item.id, project, scope) const result = await ctx.marketplace.remove(item, scope, project) if (result.success) await invalidate(ctx, scope, scope === "project" ? project! : dir) diff --git a/packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts b/packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts index 0820dc818e9..2740da6fa36 100644 --- a/packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts @@ -1,11 +1,21 @@ import { describe, expect, it } from "bun:test" import { + removable, selectedAgentNumberOverrideValue, selectedAgentTextOverrideValue, selectedDefaultAgentValue, shouldClearDefaultAgentWhenAgentBecomesUnavailable, } from "../../webview-ui/src/components/settings/agent-behaviour-patches" +describe("removable", () => { + it("only allows user-managed custom agents", () => { + expect(removable({ name: "reviewer", mode: "primary", native: false })).toBe(true) + expect(removable({ name: "code", mode: "primary", native: true })).toBe(false) + expect(removable({ name: "managed", mode: "primary", source: "organization" })).toBe(false) + expect(removable(undefined)).toBe(false) + }) +}) + describe("selectedAgentTextOverrideValue", () => { it("maps an empty text field value to a null delete sentinel", () => { expect(selectedAgentTextOverrideValue("")).toBeNull() diff --git a/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts index fc47330c300..2bbad904ef0 100644 --- a/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts +++ b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts @@ -29,6 +29,14 @@ const item: McpMarketplaceItem = { url: "", content: "", } +const agent = { + id: "reviewer", + type: "agent" as const, + name: "Code Reviewer", + description: "", + category: "development", + content: { mode: "all" as const, description: "Reviews code", prompt: "Review code" }, +} const fs = vscode.workspace.fs as unknown as { readFile: (uri: vscode.Uri) => Promise writeFile: (uri: vscode.Uri, data: Uint8Array) => Promise @@ -191,3 +199,39 @@ describe("Marketplace legacy MCP cleanup", () => { expect(has(files, global)).toBe(false) }) }) + +describe("Marketplace agent removal", () => { + it("uses the authoritative CLI removal and invalidates the resolved directory", async () => { + const remove = mock(async () => ({ data: true })) + const dispose = mock(async () => ({})) + const getClientAsync = mock(async () => ({ + kilocode: { removeAgent: remove }, + global: { config: { update: mock(async () => ({})) } }, + instance: { dispose }, + })) + const marketplace = { remove: mock(async () => ({ success: true, slug: agent.id })) } + const ctx = { connection: { getClientAsync }, marketplace } as unknown as MarketplaceActionContext + + const result = await removeMarketplaceItem(ctx, agent, "global", project, project) + + expect(result).toEqual({ success: true, slug: agent.id }) + expect(remove).toHaveBeenCalledWith({ name: agent.id, directory: project }) + expect(marketplace.remove).not.toHaveBeenCalled() + expect(dispose).toHaveBeenCalledWith({ directory: project }) + }) + + it("returns a failure when the authoritative removal rejects the agent", async () => { + const getClientAsync = mock(async () => ({ + kilocode: { removeAgent: mock(async () => ({ error: { message: "Agent is still configured" } })) }, + instance: { dispose: mock(async () => ({})) }, + })) + const ctx = { + connection: { getClientAsync }, + marketplace: { remove: mock(async () => ({ success: true, slug: agent.id })) }, + } as unknown as MarketplaceActionContext + + const result = await removeMarketplaceItem(ctx, agent, "project", project, project) + + expect(result).toEqual({ success: false, slug: agent.id, error: "Agent is still configured" }) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx index 63d45639c48..221086d7152 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx @@ -17,7 +17,7 @@ import ModeEditView from "./ModeEditView" import ModeCreateView from "./ModeCreateView" import McpEditView from "./McpEditView" import WorkflowsTab from "./agent-behaviour/WorkflowsTab" -import { selectedDefaultAgentValue } from "./agent-behaviour-patches" +import { removable, selectedDefaultAgentValue } from "./agent-behaviour-patches" import { parseImport, MAX_IMPORT_SIZE } from "./mode-io" import type { ImportError } from "./mode-io" @@ -369,6 +369,7 @@ const AgentBehaviourTab: Component = () => { {(name, index) => { const agent = () => session.allAgents().find((a) => a.name === name) const isCustom = () => !agent()?.native + const allowed = () => removable(agent()) const agentCfg = () => config().agent?.[name] ?? {} const disabled = () => agentCfg().disable ?? false const hidden = () => agentCfg().hidden ?? false @@ -478,7 +479,7 @@ const AgentBehaviourTab: Component = () => {
- + = (props) => { title={language.t("settings.agentBehaviour.exportMode")} onClick={exportMode} /> - { - const a = agent() - if (a) props.onRemove(a) - }} - /> + + { + const a = agent() + if (a) props.onRemove(a) + }} + /> +
diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/agent-behaviour-patches.ts b/packages/kilo-vscode/webview-ui/src/components/settings/agent-behaviour-patches.ts index 7d031e2a22b..02b29108c26 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/agent-behaviour-patches.ts +++ b/packages/kilo-vscode/webview-ui/src/components/settings/agent-behaviour-patches.ts @@ -1,3 +1,9 @@ +import type { AgentInfo } from "../../types/messages" + +export function removable(agent: AgentInfo | undefined): boolean { + return !!agent && !agent.native && agent.source !== "organization" +} + export function selectedDefaultAgentValue(value: string): string | null { return value || null } diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/agents.ts b/packages/kilo-vscode/webview-ui/src/types/messages/agents.ts index 4079413853d..354055d3cd3 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/agents.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/agents.ts @@ -19,6 +19,7 @@ export interface SlashCommandInfo { export interface AgentInfo { name: string displayName?: string + source?: string description?: string mode: "subagent" | "primary" | "all" native?: boolean diff --git a/packages/opencode/src/kilocode/agent/index.ts b/packages/opencode/src/kilocode/agent/index.ts index afaec2ca4cf..f8659d101a5 100644 --- a/packages/opencode/src/kilocode/agent/index.ts +++ b/packages/opencode/src/kilocode/agent/index.ts @@ -10,6 +10,7 @@ import path from "path" import { Global } from "@opencode-ai/core/global" import { Flag } from "@opencode-ai/core/flag/flag" import { applyEdits, modify, parse as parseJsonc } from "jsonc-parser" +import { KilocodeConfigSources } from "../config/sources" import PROMPT_DEBUG from "../../agent/prompt/debug.txt" import PROMPT_ORCHESTRATOR from "../../agent/prompt/orchestrator.txt" @@ -555,7 +556,13 @@ export const RemoveError = NamedError.create("AgentRemoveError", { * Scans all config directories for agent/mode .md files matching the name, * then also checks the .kilocodemodes files the ModesMigrator reads. */ -export async function remove(input: { name: string; agent?: AgentInfo; dirs: string[]; directory: string }) { +export async function remove(input: { + name: string + agent?: AgentInfo + dirs: string[] + directory: string + worktree?: string +}) { if (!input.agent) throw new RemoveError({ name: input.name, message: "agent not found" }) if (input.agent.native) throw new RemoveError({ name: input.name, message: "cannot remove native agent" }) // Prevent removal of organization-managed agents @@ -582,7 +589,7 @@ export async function remove(input: { name: string; agent?: AgentInfo; dirs: str } } - if (await removeConfigAgent(input.name, input.directory)) found = true + if (await removeConfigAgent(input.name, input.directory, input.worktree)) found = true // 2. Remove from legacy .kilocodemodes YAML files (read by ModesMigrator) const { ModesMigrator } = await import("@/kilocode/modes-migrator") @@ -616,12 +623,17 @@ export async function remove(input: { name: string; agent?: AgentInfo; dirs: str if (!found) throw new RemoveError({ name: input.name, message: "no agent file found on disk" }) } -async function removeConfigAgent(name: string, directory: string) { - const { KilocodeConfigOverlay } = await import("@/kilocode/config/overlay") - const files = [ - KilocodeConfigOverlay.globalTarget(), - await KilocodeConfigOverlay.projectTarget({ directory }), - ] +async function removeConfigAgent(name: string, directory: string, worktree?: string) { + const result = await KilocodeConfigSources.list({ directory, worktree }) + const files = result.sources + .filter( + (source) => + source.exists && + source.editable && + source.path && + ["global-file", "env-file", "project-file", "config-dir-file"].includes(source.kind), + ) + .map((source) => source.path!) let found = false for (const file of new Set(files)) { diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts index f421f5d9335..83eba54fff7 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts @@ -63,7 +63,14 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" const agent = yield* agents.get(ctx.payload.name) const dirs = yield* config.directories() yield* Effect.tryPromise({ - try: () => KiloAgent.remove({ name: ctx.payload.name, agent, dirs, directory: instance.directory }), + try: () => + KiloAgent.remove({ + name: ctx.payload.name, + agent, + dirs, + directory: instance.directory, + worktree: instance.worktree, + }), catch: (err) => err, }).pipe( Effect.catch((err) => { @@ -72,6 +79,8 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" }), ) yield* store.dispose(instance) + const remaining = yield* store.provide(instance, agents.get(ctx.payload.name)) + if (remaining && !remaining.native) return yield* Effect.fail(new HttpApiError.BadRequest({})) return true }) diff --git a/packages/opencode/test/kilocode/agent-remove.test.ts b/packages/opencode/test/kilocode/agent-remove.test.ts index 380da9a1d8a..b38c4b2be89 100644 --- a/packages/opencode/test/kilocode/agent-remove.test.ts +++ b/packages/opencode/test/kilocode/agent-remove.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "bun:test" import { mkdir } from "fs/promises" import path from "path" import { parse as parseJsonc } from "jsonc-parser" -import { remove } from "../../src/kilocode/agent" +import { RemoveError, remove } from "../../src/kilocode/agent" import type { Info as AgentInfo } from "../../src/agent/agent" import { tmpdir } from "../fixture/fixture" @@ -38,4 +38,57 @@ describe("Kilo agent remove", () => { expect(cfg.agent.reviewer).toBeUndefined() expect(cfg.agent.code.model).toBe("kilo/gpt-5") }) + + test("removes duplicate agents from every editable config source", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, ".kilo") + const files = [path.join(dir, "kilo.jsonc"), path.join(dir, "opencode.jsonc")] + await mkdir(dir, { recursive: true }) + for (const file of files) { + await Bun.write( + file, + JSON.stringify({ + default_agent: "reviewer", + agent: { + reviewer: { description: path.basename(file) }, + keep: { description: "Keep this agent" }, + }, + }), + ) + } + + await remove({ + name: "reviewer", + agent: { name: "reviewer", native: false, options: {} } as AgentInfo, + dirs: [dir], + directory: tmp.path, + }) + + for (const file of files) { + const cfg = parseJsonc(await Bun.file(file).text()) + expect(cfg.default_agent).toBeUndefined() + expect(cfg.agent.reviewer).toBeUndefined() + expect(cfg.agent.keep.description).toBe("Keep this agent") + } + }) + + test("preserves organization-managed agents", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, ".kilo", "agents") + const file = path.join(dir, "reviewer.md") + await mkdir(dir, { recursive: true }) + await Bun.write(file, "---\ndescription: Reviews code\n---\n\nReview code.\n") + + const err = await remove({ + name: "reviewer", + agent: { name: "reviewer", native: false, source: "organization", options: {} } as AgentInfo, + dirs: [path.dirname(dir)], + directory: tmp.path, + }).then( + () => undefined, + (err) => err, + ) + expect(RemoveError.isInstance(err)).toBe(true) + expect(await Bun.file(file).exists()).toBe(true) + }) }) diff --git a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts index 8375ebf7955..bdb8819e77c 100644 --- a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts +++ b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts @@ -37,6 +37,21 @@ const agent = async (dir: string) => { ) } +const duplicates = async (dir: string) => { + for (const name of ["kilo.jsonc", "opencode.jsonc"]) { + await Bun.write( + path.join(dir, ".kilo", name), + JSON.stringify({ + default_agent: "httpapi-duplicate", + agent: { + "httpapi-duplicate": { description: `Duplicate in ${name}` }, + keep: { description: "Keep this agent" }, + }, + }), + ) + } +} + function memory(ctx: ScenarioContext) { const dir = directory(ctx) return MemoryPaths.root({ ctx: { directory: dir, worktree: dir } }) @@ -575,6 +590,22 @@ export const kiloScenarios: Scenario[] = [ check(!(yield* Effect.promise(() => Bun.file(location).exists())), "removed agent should not remain on disk") }), ), + http.protected + .post("/kilocode/agent/remove", "kilocode.removeAgent.duplicates") + .inProject({ git: true, init: duplicates }) + .mutating() + .at((ctx) => ({ path: "/kilocode/agent/remove", headers: ctx.headers(), body: { name: "httpapi-duplicate" } })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "duplicate agent removal should return true") + for (const name of ["kilo.jsonc", "opencode.jsonc"]) { + const cfg = yield* Effect.promise(() => Bun.file(path.join(directory(ctx), ".kilo", name)).json()) + check(!cfg.agent["httpapi-duplicate"], `removed agent should not remain in ${name}`) + check(cfg.agent.keep.description === "Keep this agent", `unrelated agent should remain in ${name}`) + check(cfg.default_agent === undefined, `removed default agent should not remain in ${name}`) + } + }), + ), http.protected .post("/kilocode/agent/remove", "kilocode.removeAgent") .at((ctx) => ({ path: "/kilocode/agent/remove", headers: ctx.headers(), body: { name: "httpapi-missing" } })) From bfeb3ae019bb66bc1fefa28277fb8dee9db69fb8 Mon Sep 17 00:00:00 2001 From: Thomas Brugman Date: Sun, 26 Jul 2026 20:20:43 +0200 Subject: [PATCH 2/3] fix(cli): avoid eager agent reload after removal --- .../opencode/src/kilocode/server/httpapi/handlers/kilocode.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts index 83eba54fff7..3942c1d2dd3 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts @@ -79,8 +79,6 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" }), ) yield* store.dispose(instance) - const remaining = yield* store.provide(instance, agents.get(ctx.payload.name)) - if (remaining && !remaining.native) return yield* Effect.fail(new HttpApiError.BadRequest({})) return true }) From 704f67b7c36618f2794367e9ca1af466f6cbdd7f Mon Sep 17 00:00:00 2001 From: Thomas Brugman Date: Thu, 13 Aug 2026 14:43:07 +0200 Subject: [PATCH 3/3] fix: address agent removal review feedback --- .../kilo-vscode/src/services/agent-removal.ts | 11 +++- .../src/services/marketplace/actions.ts | 5 +- .../tests/unit/marketplace-actions.test.ts | 24 +++++++- packages/opencode/src/kilocode/agent/index.ts | 55 +++++++++++-------- .../server/httpapi/groups/kilocode.ts | 4 +- .../server/httpapi/handlers/kilocode.ts | 5 +- .../test/kilocode/agent-remove.test.ts | 42 +++++++++++++- .../server/httpapi-exercise-scenarios.ts | 5 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 4 +- packages/sdk/js/src/v2/gen/types.gen.ts | 1 + packages/sdk/openapi.json | 6 +- 11 files changed, 126 insertions(+), 36 deletions(-) diff --git a/packages/kilo-vscode/src/services/agent-removal.ts b/packages/kilo-vscode/src/services/agent-removal.ts index 783543cf273..df9f4583f15 100644 --- a/packages/kilo-vscode/src/services/agent-removal.ts +++ b/packages/kilo-vscode/src/services/agent-removal.ts @@ -6,24 +6,29 @@ interface Input { connection: KiloConnectionService directory: string name: string + scope?: "project" | "global" } export async function removeAgent(input: Input): Promise { try { const client = await input.connection.getClientAsync(input.directory) - const result = await client.kilocode.removeAgent({ name: input.name, directory: input.directory }) + const result = await client.kilocode.removeAgent({ + name: input.name, + directory: input.directory, + scope: input.scope, + }) if (!result.error) return { success: true, slug: input.name } return { success: false, slug: input.name, - error: getErrorMessage(result.error) ?? `Agent "${input.name}" is still provided by another configuration.`, + error: getErrorMessage(result.error) || `Agent "${input.name}" is still provided by another configuration.`, } } catch (err) { return { success: false, slug: input.name, - error: getErrorMessage(err) ?? `Failed to remove agent "${input.name}".`, + error: getErrorMessage(err) || `Failed to remove agent "${input.name}".`, } } } diff --git a/packages/kilo-vscode/src/services/marketplace/actions.ts b/packages/kilo-vscode/src/services/marketplace/actions.ts index 47cefe38b98..006043193a0 100644 --- a/packages/kilo-vscode/src/services/marketplace/actions.ts +++ b/packages/kilo-vscode/src/services/marketplace/actions.ts @@ -69,8 +69,9 @@ export async function removeMarketplaceItem( try { if (item.type === "agent") { - const result = await removeAgent({ connection: ctx.connection, directory: dir, name: item.id }) - if (result.success) await invalidate(ctx, scope, dir) + const target = scope === "project" ? project! : dir + const result = await removeAgent({ connection: ctx.connection, directory: target, name: item.id, scope }) + if (result.success) await invalidate(ctx, scope, target) return result } if (item.type === "mcp") await removeLegacyMcp(ctx, item.id, project, scope) diff --git a/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts index 2bbad904ef0..92f7045f00c 100644 --- a/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts +++ b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts @@ -215,7 +215,7 @@ describe("Marketplace agent removal", () => { const result = await removeMarketplaceItem(ctx, agent, "global", project, project) expect(result).toEqual({ success: true, slug: agent.id }) - expect(remove).toHaveBeenCalledWith({ name: agent.id, directory: project }) + expect(remove).toHaveBeenCalledWith({ name: agent.id, directory: project, scope: "global" }) expect(marketplace.remove).not.toHaveBeenCalled() expect(dispose).toHaveBeenCalledWith({ directory: project }) }) @@ -234,4 +234,26 @@ describe("Marketplace agent removal", () => { expect(result).toEqual({ success: false, slug: agent.id, error: "Agent is still configured" }) }) + + it("uses friendly fallbacks for empty backend errors", async () => { + const remove = mock(async () => ({ error: new Error("") })) + const getClientAsync = mock(async () => ({ kilocode: { removeAgent: remove } })) + const ctx = { + connection: { getClientAsync }, + marketplace: { remove: mock(async () => ({ success: true, slug: agent.id })) }, + } as unknown as MarketplaceActionContext + + const rejected = await removeMarketplaceItem(ctx, agent, "project", project, project) + expect(rejected).toEqual({ + success: false, + slug: agent.id, + error: `Agent "${agent.id}" is still provided by another configuration.`, + }) + + getClientAsync.mockImplementation(async () => { + throw new Error("") + }) + const failed = await removeMarketplaceItem(ctx, agent, "global", project, project) + expect(failed).toEqual({ success: false, slug: agent.id, error: `Failed to remove agent "${agent.id}".` }) + }) }) diff --git a/packages/opencode/src/kilocode/agent/index.ts b/packages/opencode/src/kilocode/agent/index.ts index d4bc9fa35c5..dda8578f628 100644 --- a/packages/opencode/src/kilocode/agent/index.ts +++ b/packages/opencode/src/kilocode/agent/index.ts @@ -547,8 +547,7 @@ export const RemoveError = NamedError.create("AgentRemoveError", { /** * Remove a custom agent by deleting its markdown source file, removing it from * config-backed agent entries, and/or removing it from legacy .kilocodemodes YAML files. - * Scans all config directories for agent/mode .md files matching the name, - * then also checks the .kilocodemodes files the ModesMigrator reads. + * Scans the selected writable config scope, or every scope when none is selected. */ export async function remove(input: { name: string @@ -556,6 +555,7 @@ export async function remove(input: { dirs: string[] directory: string worktree?: string + scope?: "global" | "project" }) { if (!input.agent) throw new RemoveError({ name: input.name, message: "agent not found" }) if (input.agent.native) throw new RemoveError({ name: input.name, message: "cannot remove native agent" }) @@ -568,10 +568,21 @@ export async function remove(input: { const { unlink, writeFile } = await import("fs/promises") let found = false + const result = await KilocodeConfigSources.list({ directory: input.directory, worktree: input.worktree }) + const sources = result.sources.filter((source) => !input.scope || source.scope === input.scope) + const roots = new Set( + sources.flatMap((source) => { + if (!source.path) return [] + if (source.kind === "config-dir") return [source.path] + if (source.kind === "global-file") return [path.dirname(source.path)] + return [] + }), + ) + const dirs = input.scope ? input.dirs.filter((dir) => roots.has(dir)) : input.dirs // 1. Delete .md files from config directories const patterns = ["{agent,agents}/**/" + input.name + ".md", "{mode,modes}/" + input.name + ".md"] - for (const dir of input.dirs) { + for (const dir of dirs) { for (const pattern of patterns) { const matches = await Glob.scan(pattern, { cwd: dir, absolute: true, dot: true }) for (const file of matches) { @@ -583,7 +594,7 @@ export async function remove(input: { } } - if (await removeConfigAgent(input.name, input.directory, input.worktree)) found = true + if (await removeConfigAgent(input.name, sources)) found = true // 2. Remove from legacy .kilocodemodes YAML files (read by ModesMigrator) const { ModesMigrator } = await import("@/kilocode/modes-migrator") @@ -591,15 +602,22 @@ export async function remove(input: { const os = await import("os") const matter = (await import("gray-matter")).default const home = os.default.homedir() - const modesFiles = [ - path.join(KilocodePaths.vscodeGlobalStorage(), "settings", "custom_modes.yaml"), - path.join(home, ".kilocode", "cli", "global", "settings", "custom_modes.yaml"), - path.join(home, ".kilocodemodes"), - path.join(input.directory, ".kilocodemodes"), + const legacy = [ + { + scope: "global" as const, + file: path.join(KilocodePaths.vscodeGlobalStorage(), "settings", "custom_modes.yaml"), + }, + { + scope: "global" as const, + file: path.join(home, ".kilocode", "cli", "global", "settings", "custom_modes.yaml"), + }, + { scope: "global" as const, file: path.join(home, ".kilocodemodes") }, + { scope: "project" as const, file: path.join(input.directory, ".kilocodemodes") }, ] - for (const file of modesFiles) { - const modes = await ModesMigrator.readModesFile(file) + for (const item of legacy) { + if (input.scope && item.scope !== input.scope) continue + const modes = await ModesMigrator.readModesFile(item.file) if (!modes.length) continue const filtered = modes.filter((m: { slug: string }) => m.slug !== input.name) @@ -610,23 +628,16 @@ export async function remove(input: { .stringify("", { customModes: filtered }) .replace(/^---\n/, "") .replace(/\n---\n?$/, "") - await writeFile(file, yaml) + await writeFile(item.file, yaml) found = true } if (!found) throw new RemoveError({ name: input.name, message: "no agent file found on disk" }) } -async function removeConfigAgent(name: string, directory: string, worktree?: string) { - const result = await KilocodeConfigSources.list({ directory, worktree }) - const files = result.sources - .filter( - (source) => - source.exists && - source.editable && - source.path && - ["global-file", "env-file", "project-file", "config-dir-file"].includes(source.kind), - ) +async function removeConfigAgent(name: string, sources: KilocodeConfigSources.Source[]) { + const files = sources + .filter((source) => source.exists && source.editable && source.path && source.kind.endsWith("-file")) .map((source) => source.path!) let found = false diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts index fa48f84e9c7..b5c486ee970 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts @@ -27,6 +27,7 @@ import { SessionID } from "@/session/schema" import { CommandFiles } from "@/kilocode/command-files" const root = "/kilocode" +const Scope = Schema.Literals(["global", "project"]) export const RemoveSkillPayload = Schema.Struct({ location: Schema.String, @@ -38,6 +39,7 @@ export const RemoveCommandPayload = Schema.Struct({ export const RemoveAgentPayload = Schema.Struct({ name: Schema.String, + scope: Schema.optional(Scope), }) export const AgentRequirementQuery = Schema.Struct({ @@ -134,7 +136,7 @@ export const KilocodeApi = HttpApi.make("kilocode") identifier: "kilocode.removeAgent", summary: "Remove a custom agent", description: - "Remove a custom (non-native) agent by deleting its markdown file from disk and refreshing state.", + "Remove a custom (non-native) agent from one writable configuration scope, or every writable scope when omitted, and dispose cached instance state.", }), ), HttpApiEndpoint.get("notebookList", KilocodePaths.notebookList, { diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts index e587f34cfe2..922a9dbfa04 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts @@ -15,6 +15,7 @@ import { Notebook } from "@/kilocode/notebook/service" import { ModelUsage } from "@/kilocode/session/model-usage" import { InstanceStore } from "@/project/instance-store" import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api" +import { InvalidRequestError } from "@/server/routes/instance/httpapi/errors" import { Skill } from "@/skill" import type { SessionID } from "@/session/schema" import { @@ -102,11 +103,13 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" dirs, directory: instance.directory, worktree: instance.worktree, + scope: ctx.payload.scope, }), catch: (err) => err, }).pipe( Effect.catch((err) => { - if (KiloAgent.RemoveError.isInstance(err)) return Effect.fail(new HttpApiError.BadRequest({})) + if (KiloAgent.RemoveError.isInstance(err)) + return Effect.fail(new InvalidRequestError({ message: err.data.message })) return Effect.die(err) }), ) diff --git a/packages/opencode/test/kilocode/agent-remove.test.ts b/packages/opencode/test/kilocode/agent-remove.test.ts index b38c4b2be89..bc6d133782a 100644 --- a/packages/opencode/test/kilocode/agent-remove.test.ts +++ b/packages/opencode/test/kilocode/agent-remove.test.ts @@ -1,7 +1,8 @@ // kilocode_change - new file import { describe, expect, test } from "bun:test" -import { mkdir } from "fs/promises" +import { mkdir, rm } from "fs/promises" import path from "path" +import { Global } from "@opencode-ai/core/global" import { parse as parseJsonc } from "jsonc-parser" import { RemoveError, remove } from "../../src/kilocode/agent" import type { Info as AgentInfo } from "../../src/agent/agent" @@ -13,7 +14,9 @@ describe("Kilo agent remove", () => { const dir = path.join(tmp.path, ".kilo") const file = path.join(dir, "kilo.jsonc") await mkdir(dir, { recursive: true }) - await Bun.write(file, `{ + await Bun.write( + file, + `{ // imported agent "default_agent": "reviewer", "agent": { @@ -24,7 +27,8 @@ describe("Kilo agent remove", () => { "model": "kilo/gpt-5" } } -}`) +}`, + ) await remove({ name: "reviewer", @@ -72,6 +76,38 @@ describe("Kilo agent remove", () => { } }) + test("limits removal to the selected scope", async () => { + await using tmp = await tmpdir() + const name = "scope-reviewer" + const dir = path.join(tmp.path, ".kilo") + const local = path.join(dir, "kilo.jsonc") + const global = path.join(Global.Path.config, "kilo.jsonc") + const previous = (await Bun.file(global).exists()) ? await Bun.file(global).text() : undefined + const content = JSON.stringify({ agent: { [name]: { description: "Reviews code" } } }) + await mkdir(dir, { recursive: true }) + await mkdir(Global.Path.config, { recursive: true }) + await Bun.write(local, content) + await Bun.write(global, content) + + try { + const agent = { name, native: false, options: {} } as AgentInfo + const dirs = [dir, Global.Path.config] + await remove({ name, agent, dirs, directory: tmp.path, scope: "project" }) + + expect(parseJsonc(await Bun.file(local).text()).agent[name]).toBeUndefined() + expect(parseJsonc(await Bun.file(global).text()).agent[name]).toBeDefined() + + await Bun.write(local, content) + await remove({ name, agent, dirs, directory: tmp.path, scope: "global" }) + + expect(parseJsonc(await Bun.file(local).text()).agent[name]).toBeDefined() + expect(parseJsonc(await Bun.file(global).text()).agent[name]).toBeUndefined() + } finally { + if (previous === undefined) await rm(global, { force: true }) + else await Bun.write(global, previous) + } + }) + test("preserves organization-managed agents", async () => { await using tmp = await tmpdir() const dir = path.join(tmp.path, ".kilo", "agents") diff --git a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts index cc28a3489c4..38a0f0d2f90 100644 --- a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts +++ b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts @@ -654,7 +654,10 @@ export const kiloScenarios: Scenario[] = [ http.protected .post("/kilocode/agent/remove", "kilocode.removeAgent") .at((ctx) => ({ path: "/kilocode/agent/remove", headers: ctx.headers(), body: { name: "httpapi-missing" } })) - .status(400), + .json(400, (body) => { + object(body) + check(body.message === "agent not found", "agent removal should preserve the backend error message") + }), http.protected .post("/kilocode/session-import/project", "kilocode.sessionImport.project") .mutating() diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 965b7b43c24..ac28034a203 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -8227,13 +8227,14 @@ export class Kilocode extends HeyApiClient { /** * Remove a custom agent * - * Remove a custom (non-native) agent by deleting its markdown file from disk and refreshing state. + * Remove a custom (non-native) agent from one writable configuration scope, or every writable scope when omitted, and dispose cached instance state. */ public removeAgent( parameters?: { directory?: string workspace?: string name?: string + scope?: "global" | "project" }, options?: Options, ) { @@ -8245,6 +8246,7 @@ export class Kilocode extends HeyApiClient { { in: "query", key: "directory" }, { in: "query", key: "workspace" }, { in: "body", key: "name" }, + { in: "body", key: "scope" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 14d6826fb83..13136a64a9a 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -16610,6 +16610,7 @@ export type KilocodeRemoveSkillResponse = KilocodeRemoveSkillResponses[keyof Kil export type KilocodeRemoveAgentData = { body?: { name: string + scope?: "global" | "project" } path?: never query?: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 7c4f1e393f3..9a1c6a07034 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -15251,7 +15251,7 @@ } } }, - "description": "Remove a custom (non-native) agent by deleting its markdown file from disk and refreshing state.", + "description": "Remove a custom (non-native) agent from one writable configuration scope, or every writable scope when omitted, and dispose cached instance state.", "summary": "Remove a custom agent", "requestBody": { "content": { @@ -15261,6 +15261,10 @@ "properties": { "name": { "type": "string" + }, + "scope": { + "type": "string", + "enum": ["global", "project"] } }, "required": ["name"],