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 7c17a21398a..144b7fdd613 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" @@ -221,6 +222,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, @@ -2611,14 +2613,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..df9f4583f15 --- /dev/null +++ b/packages/kilo-vscode/src/services/agent-removal.ts @@ -0,0 +1,34 @@ +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 + 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, + 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.`, + } + } 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..006043193a0 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,12 @@ export async function removeMarketplaceItem( } try { + if (item.type === "agent") { + 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) 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 796a2d0eebc..60dbea402b0 100644 --- a/packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-behaviour-patches.test.ts @@ -2,12 +2,22 @@ import { describe, expect, it } from "bun:test" import { mcpConfigScope, mcpEnabledPatch, + 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("mcpEnabledPatch", () => { it("returns only the enabled-state patch", () => { expect(mcpEnabledPatch("docs", false)).toEqual({ diff --git a/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts b/packages/kilo-vscode/tests/unit/marketplace-actions.test.ts index fc47330c300..92f7045f00c 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,61 @@ 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, scope: "global" }) + 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" }) + }) + + 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/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx index e97b1cff6df..37d04b6d63a 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx @@ -18,7 +18,7 @@ import ModeEditView from "./ModeEditView" import ModeCreateView from "./ModeCreateView" import McpEditView from "./McpEditView" import WorkflowsTab from "./agent-behaviour/WorkflowsTab" -import { mcpConfigScope, mcpEnabledPatch, selectedDefaultAgentValue } from "./agent-behaviour-patches" +import { mcpConfigScope, mcpEnabledPatch, removable, selectedDefaultAgentValue } from "./agent-behaviour-patches" import { parseImport, MAX_IMPORT_SIZE } from "./mode-io" import type { ImportError } from "./mode-io" @@ -368,6 +368,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 @@ -477,7 +478,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 eabc6550c59..932aa56c919 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,4 +1,8 @@ -import type { Config, ConfigCollections } from "../../types/messages" +import type { AgentInfo, Config, ConfigCollections } from "../../types/messages" + +export function removable(agent: AgentInfo | undefined): boolean { + return !!agent && !agent.native && agent.source !== "organization" +} export function mcpEnabledPatch(name: string, enabled: boolean): Partial { return { 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 90a69408180..928f3c6aaa4 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/agents.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/agents.ts @@ -22,6 +22,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 3fcecd44902..bda511646cc 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" @@ -570,10 +571,16 @@ 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; agent?: AgentInfo; dirs: string[]; directory: string }) { +export async function remove(input: { + name: string + agent?: AgentInfo + 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" }) // Prevent removal of organization-managed agents @@ -585,10 +592,21 @@ export async function remove(input: { name: string; agent?: AgentInfo; dirs: str 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) { @@ -600,7 +618,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, sources)) found = true // 2. Remove from legacy .kilocodemodes YAML files (read by ModesMigrator) const { ModesMigrator } = await import("@/kilocode/modes-migrator") @@ -608,15 +626,22 @@ export async function remove(input: { name: string; agent?: AgentInfo; dirs: str 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) @@ -627,19 +652,17 @@ export async function remove(input: { name: string; agent?: AgentInfo; dirs: str .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) { - const { KilocodeConfigOverlay } = await import("@/kilocode/config/overlay") - const files = [ - KilocodeConfigOverlay.globalTarget(), - await KilocodeConfigOverlay.projectTarget({ directory }), - ] +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 for (const file of new Set(files)) { 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 229dadf9602..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 { @@ -95,11 +96,20 @@ 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, + 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 380da9a1d8a..bc6d133782a 100644 --- a/packages/opencode/test/kilocode/agent-remove.test.ts +++ b/packages/opencode/test/kilocode/agent-remove.test.ts @@ -1,9 +1,10 @@ // 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 { 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" @@ -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", @@ -38,4 +42,89 @@ 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("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") + 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 7e0df05fce0..53f65a371b7 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" }, + }, + }), + ) + } +} + const command = async (dir: string) => { await Bun.write( path.join(dir, ".kilo/command/httpapi-remove.md"), @@ -623,10 +638,29 @@ 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" } })) - .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 4927916a5a0..b4d93759830 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -16664,6 +16664,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 10461bb9aa1..fc33e89cc17 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -15258,7 +15258,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": { @@ -15268,6 +15268,10 @@ "properties": { "name": { "type": "string" + }, + "scope": { + "type": "string", + "enum": ["global", "project"] } }, "required": ["name"],