Skip to content
7 changes: 6 additions & 1 deletion packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export type RequirementBlockedError = InstanceType<typeof AgentRequirements.Bloc
export const Info = Schema.Struct({
name: Schema.String,
displayName: Schema.optional(Schema.String), // kilocode_change - human-readable name for org modes
source: Schema.optional(Schema.String), // kilocode_change - origin marker (organization | global | project)
description: Schema.optional(Schema.String),
deprecated: Schema.optional(Schema.Boolean), // kilocode_change
mode: Schema.Literals(["subagent", "primary", "all"]),
Expand Down Expand Up @@ -334,7 +335,11 @@ export const layer = Layer.effect(
item.hidden = value.hidden ?? item.hidden
item.name = value.name ?? item.name
item.steps = value.steps ?? item.steps
item.requirements = value.requirements ?? item.requirements // kilocode_change
// kilocode_change start - carry metadata as typed fields, never as provider options
item.displayName = value.displayName ?? item.displayName
item.source = value.source ?? item.source
item.requirements = value.requirements ?? item.requirements
// kilocode_change end
item.options = mergeDeep(item.options, value.options ?? {})
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
KiloAgent.processConfigItem(item) // kilocode_change - populate displayName from options
Expand Down
10 changes: 10 additions & 0 deletions packages/opencode/src/config/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ const AgentSchema = Schema.StructWithRest(
}),
// kilocode_change end
mode: Schema.optional(Schema.Literals(["subagent", "primary", "all"])),
// kilocode_change start - typed metadata carriers so they never fall into `options` (provider params)
displayName: Schema.optional(Schema.String).annotate({
description: "Human-readable name shown in the UI (e.g. for organization or marketplace agents)",
}),
source: Schema.optional(Schema.String).annotate({
description: "Origin marker for managed agents (organization | global | project)",
}),
// kilocode_change end
hidden: Schema.optional(Schema.Boolean).annotate({
description: "Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)",
}),
Expand Down Expand Up @@ -76,6 +84,8 @@ const KNOWN_KEYS = new Set([
"temperature",
"top_p",
"mode",
"displayName", // kilocode_change
"source", // kilocode_change
"hidden",
"color",
"steps",
Expand Down
19 changes: 16 additions & 3 deletions packages/opencode/src/kilocode/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,15 +264,27 @@ export function preprocessConfig<T>(agentConfig: Record<string, T>): Record<stri
return result
}

// Set displayName and deprecated from options after config item is processed.
// Lift Kilo-internal metadata onto typed agent fields and remove it from `options`.
// Older org modes and marketplace agents stored `displayName`/`source` inside the
// `options` record, which is otherwise forwarded verbatim to the provider as request
// parameters. Promoting then deleting them keeps `options` provider-clean at the source
// (the request boundary still strips as a safety net).
export function processConfigItem(item: {
options: Record<string, unknown>
displayName?: string
source?: string
deprecated?: boolean
}) {
if (item.options?.displayName && typeof item.options.displayName === "string") {
if (!item.displayName && typeof item.options?.displayName === "string") {
item.displayName = item.options.displayName
}
if (!item.source && typeof item.options?.source === "string") {
item.source = item.options.source
}
if (item.options) {
delete item.options.displayName
delete item.options.source
}
}

const locked = new Set(["compaction", "title", "summary"])
Expand Down Expand Up @@ -319,6 +331,7 @@ export function patchAgents(
{
name: string
displayName?: string
source?: string
description?: string
deprecated?: boolean
mode: "subagent" | "primary" | "all"
Expand Down Expand Up @@ -495,7 +508,7 @@ export async function remove(input: { name: string; agent?: AgentInfo; dirs: str
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
if (input.agent.options?.source === "organization")
if (input.agent.source === "organization" || input.agent.options?.source === "organization")
throw new RemoveError({
name: input.name,
message: "cannot remove organization agent — manage it from the cloud dashboard",
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/kilocode/modes-migrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,9 @@ export namespace ModesMigrator {
description: cfg.description ?? cfg.whenToUse ?? mode.name,
prompt: prompt || undefined,
permission: convertPermissions(groups),
options: { source: "organization", displayName: mode.name },
// Typed metadata fields — must NOT live in `options`, which is forwarded to the provider.
displayName: mode.name,
source: "organization",
}
}

Expand Down
27 changes: 27 additions & 0 deletions packages/opencode/test/kilocode/agent-config-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test"
import { processConfigItem } from "../../src/kilocode/agent"

describe("processConfigItem", () => {
test("lifts legacy options-based metadata to typed fields and strips it", () => {
const item: { options: Record<string, unknown>; displayName?: string; source?: string } = {
options: { displayName: "Code Reviewer", source: "organization", reasoningEffort: "high" },
}
processConfigItem(item)
expect(item.displayName).toBe("Code Reviewer")
expect(item.source).toBe("organization")
// metadata removed from options, genuine provider options preserved
expect(item.options).toEqual({ reasoningEffort: "high" })
})

test("does not overwrite metadata already set as typed fields", () => {
const item: { options: Record<string, unknown>; displayName?: string; source?: string } = {
displayName: "Typed Name",
source: "organization",
options: { displayName: "Legacy Name", source: "global" },
}
processConfigItem(item)
expect(item.displayName).toBe("Typed Name")
expect(item.source).toBe("organization")
expect(item.options).toEqual({})
})
})
24 changes: 24 additions & 0 deletions packages/opencode/test/kilocode/modes-migrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,30 @@ describe("ModesMigrator", () => {
})
})

describe("convertOrganizationMode", () => {
const mode = {
id: "11111111-1111-1111-1111-111111111111",
organization_id: "org-1",
name: "Code Review custom Agent",
slug: "code-review",
created_by: "user-1",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
config: {
roleDefinition: "You are a reviewer.",
groups: ["read"],
},
}

test("carries displayName and source as typed fields, not provider options", () => {
const agent = ModesMigrator.convertOrganizationMode(mode)
expect(agent.displayName).toBe("Code Review custom Agent")
expect(agent.source).toBe("organization")
// The metadata must never live in `options`, which is forwarded to the provider.
expect(agent.options).toBeUndefined()
})
})

describe("readModesFile", () => {
test("returns empty array for non-existent file", async () => {
const modes = await ModesMigrator.readModesFile("/non/existent/path.yaml")
Expand Down
Loading