Merge pull request #13225 from Kilo-Org/busy-computer

fix: remove agent requirements feature
This commit is contained in:
Marius
2026-08-19 12:39:42 +02:00
committed by GitHub
73 changed files with 78 additions and 3315 deletions
+2 -42
View File
@@ -30,9 +30,7 @@ import { AbsolutePath, type DeepMutable } from "@opencode-ai/core/schema"
// kilocode_change start
import * as KiloAgent from "@/kilocode/agent"
import { RuntimeFlags } from "@/effect/runtime-flags"
import * as AgentRequirements from "@/kilocode/agent-requirements"
import * as KiloReference from "@/kilocode/reference"
import { MCP } from "@/mcp"
// kilocode_change end
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
@@ -41,8 +39,6 @@ import { Reference } from "@opencode-ai/core/reference"
import { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin"
export type RequirementBlockedError = InstanceType<typeof AgentRequirements.BlockedError> // kilocode_change
export const Info = Schema.Struct({
name: Schema.String,
// kilocode_change start
@@ -67,7 +63,6 @@ export const Info = Schema.Struct({
variant: Schema.optional(Schema.String),
prompt: Schema.optional(Schema.String),
options: Schema.Record(Schema.String, Schema.Unknown),
requirements: Schema.optional(AgentRequirements.Requirements), // kilocode_change
steps: Schema.optional(Schema.Finite),
}).annotate({ identifier: "Agent" })
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
@@ -83,10 +78,6 @@ export interface Interface {
readonly list: () => Effect.Effect<Info[]>
readonly defaultInfo: () => Effect.Effect<Info>
readonly defaultAgent: () => Effect.Effect<string>
// kilocode_change start
readonly requirementStatus: (agent: string) => Effect.Effect<AgentRequirements.Result>
readonly guardRequirements: (agent: Info) => Effect.Effect<void, RequirementBlockedError>
// kilocode_change end
readonly generate: (input: {
description: string
model?: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
@@ -100,7 +91,7 @@ export interface Interface {
>
}
type State = Omit<Interface, "generate" | "requirementStatus" | "guardRequirements"> & { version: string } // kilocode_change
type State = Omit<Interface, "generate"> & { version: string } // kilocode_change
export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
@@ -113,7 +104,6 @@ const layer = Layer.effect(
const auth = yield* Auth.Service
const plugin = yield* Plugin.Service
const skill = yield* Skill.Service
const mcp = yield* MCP.Service // kilocode_change
const provider = yield* Provider.Service
const flags = yield* RuntimeFlags.Service // kilocode_change
const locations = yield* LocationServiceMap.Service
@@ -376,7 +366,6 @@ const layer = Layer.effect(
// 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 ?? {}))
@@ -536,31 +525,6 @@ const layer = Layer.effect(
return yield* select(yield* InstanceState.get(state))
})
const requirementStatus = Effect.fn("Agent.requirementStatus")(function* (name: string) {
const ctx = yield* InstanceState.context
return yield* AgentRequirements.status({
name,
directory: ctx.directory,
config,
skills: skill,
mcp,
agents: { get: (agent) => current((s) => s.get(agent)) },
})
})
const guardRequirements = Effect.fn("Agent.guardRequirements")(function* (agent: Info) {
const ctx = yield* InstanceState.context
yield* AgentRequirements.guard({
agent,
directory: ctx.directory,
config,
skills: skill,
mcp,
agents: { get: (name) => current((s) => s.get(name)) },
})
})
// kilocode_change end
return Service.of({
get: Effect.fn("Agent.get")(function* (agent: string) {
return yield* current((s) => s.get(agent)) // kilocode_change
@@ -574,10 +538,6 @@ const layer = Layer.effect(
defaultAgent: Effect.fn("Agent.defaultAgent")(function* () {
return yield* current((s) => s.defaultAgent()) // kilocode_change
}),
// kilocode_change start
requirementStatus,
guardRequirements,
// kilocode_change end
generate: Effect.fn("Agent.generate")(function* (input: {
description: string
model?: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
@@ -653,7 +613,7 @@ const locationServiceMapNode = LayerNode.make({
export const node = LayerNode.make({
service: Service,
layer: layer,
deps: [Config.node, Auth.node, Plugin.node, Skill.node, Provider.node, MCP.node, RuntimeFlags.node, locationServiceMapNode], // kilocode_change
deps: [Config.node, Auth.node, Plugin.node, Skill.node, Provider.node, RuntimeFlags.node, locationServiceMapNode], // kilocode_change
})
export * as Agent from "./agent"
@@ -1,261 +0,0 @@
import type { Agent } from "@/agent/agent"
import type { Config } from "@/config/config"
import type { MCP } from "@/mcp"
import type { Skill } from "@/skill"
import { Flag } from "@opencode-ai/core/flag/flag"
import { NamedError } from "@opencode-ai/core/util/error"
import { Cause, Effect, Exit, Schema } from "effect"
import { ConfigAgentV1 } from "@opencode-ai/core/v1/config/agent"
export const VSCodeExtension = ConfigAgentV1.VSCodeExtension
export type VSCodeExtension = ConfigAgentV1.VSCodeExtension
export const Requirements = ConfigAgentV1.Requirements
export type Requirements = ConfigAgentV1.Requirements
export const SkillItem = Schema.Struct({
name: Schema.String,
status: Schema.Literals(["ready", "missing", "error"]),
message: Schema.optional(Schema.String),
})
export type SkillItem = Schema.Schema.Type<typeof SkillItem>
export const MCPItem = Schema.Struct({
name: Schema.String,
status: Schema.Literals(["ready", "missing", "error"]),
message: Schema.optional(Schema.String),
})
export type MCPItem = Schema.Schema.Type<typeof MCPItem>
export const Result = Schema.Struct({
agent: Schema.String,
directory: Schema.String,
enabled: Schema.Boolean,
state: Schema.Literals(["disabled", "ready", "blocked", "error"]),
skills: Schema.Array(SkillItem),
mcps: Schema.Array(MCPItem),
vscode_extensions: Schema.Array(VSCodeExtension),
error: Schema.optional(
Schema.Struct({
code: Schema.Literals(["unknown_agent", "malformed_declaration", "discovery_failed", "mcp_status_failed"]),
message: Schema.String,
}),
),
}).annotate({ identifier: "AgentRequirementResult" })
export type Result = Schema.Schema.Type<typeof Result>
export const BlockedError = NamedError.create("AgentRequirementError", {
message: Schema.String,
agent: Schema.String,
directory: Schema.String,
state: Schema.Literals(["blocked", "error"]),
skills: Schema.mutable(Schema.Array(SkillItem)),
mcps: Schema.mutable(Schema.Array(MCPItem)),
vscode_extensions: Schema.mutable(Schema.Array(VSCodeExtension)),
})
type AgentInfo = Pick<Agent.Info, "name"> & { requirements?: unknown }
type Services = {
config: Pick<Config.Interface, "get">
agents: { get: (agent: string) => Effect.Effect<AgentInfo | undefined> }
skills: Pick<Skill.Interface, "all">
mcp: Pick<MCP.Interface, "status">
}
function enabled(cfg: Config.Info) {
const experimental = cfg.experimental
return experimental !== undefined && "agent_requirements" in experimental && experimental.agent_requirements === true
}
function ready(input: { agent: string; directory: string; enabled: boolean }): Result {
return {
...input,
state: input.enabled ? "ready" : "disabled",
skills: [],
mcps: [],
vscode_extensions: [],
}
}
function malformed(agent: AgentInfo, directory: string, message: string): Result {
return {
agent: agent.name,
directory,
enabled: true,
state: "error",
skills: [],
mcps: [],
vscode_extensions: [],
error: { code: "malformed_declaration", message },
}
}
function decode(agent: AgentInfo, directory: string): Requirements | Result {
if (agent.requirements === undefined) return ready({ agent: agent.name, directory, enabled: true })
const decoded = Schema.decodeUnknownExit(Requirements)(agent.requirements, {
errors: "all",
propertyOrder: "original",
})
if (Exit.isFailure(decoded)) return malformed(agent, directory, Cause.pretty(decoded.cause))
return decoded.value
}
function item(name: string, status: MCP.Status | undefined): MCPItem {
if (status?.status === "connected") return { name, status: "ready" }
if (status?.status === "failed" || status?.status === "needs_client_registration") {
return { name, status: "error", message: status.error }
}
return { name, status: "missing" }
}
export function evaluate(input: {
agent: AgentInfo
directory: string
enabled: boolean
requirements: Requirements
discovered?: ReadonlySet<string>
discoveryError?: string
mcp?: Readonly<Record<string, MCP.Status>>
mcpError?: string
}): Result {
if (!input.enabled) return ready({ agent: input.agent.name, directory: input.directory, enabled: false })
const skills = (input.requirements.skills ?? []).map((skill) => ({
name: skill,
status: input.discoveryError
? ("error" as const)
: input.discovered?.has(skill)
? ("ready" as const)
: ("missing" as const),
...(input.discoveryError ? { message: input.discoveryError } : {}),
}))
const mcps = (input.requirements.mcps ?? []).map((name) => {
if (input.mcpError) return { name, status: "error" as const, message: input.mcpError }
return item(name, input.mcp?.[name])
})
if (input.discoveryError) {
return {
agent: input.agent.name,
directory: input.directory,
enabled: true,
state: "error",
skills,
mcps,
vscode_extensions: input.requirements.vscode_extensions ?? [],
error: { code: "discovery_failed", message: input.discoveryError },
}
}
if (input.mcpError) {
return {
agent: input.agent.name,
directory: input.directory,
enabled: true,
state: "error",
skills,
mcps,
vscode_extensions: input.requirements.vscode_extensions ?? [],
error: { code: "mcp_status_failed", message: input.mcpError },
}
}
const valid = skills.every((skill) => skill.status === "ready") && mcps.every((mcp) => mcp.status === "ready")
return {
agent: input.agent.name,
directory: input.directory,
enabled: true,
state: valid ? "ready" : "blocked",
skills,
mcps,
vscode_extensions: input.requirements.vscode_extensions ?? [],
}
}
export const status = Effect.fn("AgentRequirements.status")(function* (
input: Services & { name: string; directory: string },
) {
const cfg = yield* input.config.get()
const active = enabled(cfg)
if (!active) return ready({ agent: input.name, directory: input.directory, enabled: false })
const agent = yield* input.agents.get(input.name)
if (!agent) {
return {
agent: input.name,
directory: input.directory,
enabled: true,
state: "error",
skills: [],
mcps: [],
vscode_extensions: [],
error: { code: "unknown_agent", message: `Agent not found: ${input.name}` },
} satisfies Result
}
const requirements = decode(agent, input.directory)
if ("state" in requirements) return requirements
const skillStatus: Effect.Effect<Skill.Info[]> = requirements.skills?.length ? input.skills.all() : Effect.succeed([])
const mcpStatus: Effect.Effect<Record<string, MCP.Status>> = requirements.mcps?.length
? input.mcp.status()
: Effect.succeed({})
const [discovered, mcp] = yield* Effect.all([skillStatus.pipe(Effect.exit), mcpStatus.pipe(Effect.exit)])
const discoveredSet: ReadonlySet<string> | undefined = Exit.isSuccess(discovered)
? new Set(discovered.value.map((skill) => skill.name))
: undefined
if (Exit.isFailure(discovered)) {
return evaluate({
agent,
directory: input.directory,
enabled: active,
requirements,
discoveryError: Cause.pretty(discovered.cause),
mcp: Exit.isSuccess(mcp) ? mcp.value : undefined,
})
}
if (Exit.isFailure(mcp)) {
return evaluate({
agent,
directory: input.directory,
enabled: active,
requirements,
discovered: discoveredSet,
mcpError: Cause.pretty(mcp.cause),
})
}
return evaluate({
agent,
directory: input.directory,
enabled: active,
requirements,
discovered: discoveredSet,
mcp: mcp.value,
})
})
export const guard = Effect.fn("AgentRequirements.guard")(function* (
input: Services & { agent: AgentInfo; directory: string },
) {
const result = yield* status({ ...input, name: input.agent.name })
const unsupported = Flag.KILO_CLIENT !== "vscode" && result.vscode_extensions.length > 0
if (result.state === "disabled" || (result.state === "ready" && !unsupported)) return
const state = result.state === "error" ? result.state : "blocked"
return yield* Effect.fail(
new BlockedError({
message: "Complete the required checks to use this agent first",
agent: result.agent,
directory: result.directory,
state,
skills: [...result.skills],
mcps: [...result.mcps],
vscode_extensions: [...result.vscode_extensions],
}),
)
})
@@ -1,116 +0,0 @@
import type { AgentRequirementResult } from "@kilocode/sdk/v2"
import { isRecord } from "@/util/record"
type Item = AgentRequirementResult["skills"][number] | AgentRequirementResult["mcps"][number]
export type Client = {
kilocode: {
agentRequirements: (
parameters: { agent: string; directory: string },
options: { throwOnError: true },
) => Promise<{ data: AgentRequirementResult }>
}
}
export type ErrorData = {
message: string
agent: string
directory: string
state: "blocked" | "error"
skills: AgentRequirementResult["skills"]
mcps: AgentRequirementResult["mcps"]
vscode_extensions: AgentRequirementResult["vscode_extensions"]
}
export type RequirementError = {
name: "AgentRequirementError"
data: ErrorData
}
export type Check =
| { ok: true; result: AgentRequirementResult }
| { ok: false; result: AgentRequirementResult; error: RequirementError }
export async function check(input: { client: Client; agent: string; directory: string }): Promise<Check> {
const response = await input.client.kilocode.agentRequirements(
{ agent: input.agent, directory: input.directory },
{ throwOnError: true },
)
if (!blocked(response.data)) return { ok: true, result: response.data }
return { ok: false, result: response.data, error: toError(response.data) }
}
export function blocked(result: AgentRequirementResult) {
if (!result.enabled || result.state === "disabled") return false
if (result.vscode_extensions.length > 0) return true
return result.state === "blocked" || result.state === "error"
}
export function toError(result: AgentRequirementResult): RequirementError {
const info = {
message: result.error?.message ?? "Complete the required checks to use this agent first",
agent: result.agent,
directory: result.directory,
state: result.state === "error" ? ("error" as const) : ("blocked" as const),
skills: result.skills,
mcps: result.mcps,
vscode_extensions: result.vscode_extensions,
}
return {
name: "AgentRequirementError",
data: {
...info,
message: format(info),
},
}
}
export function data(input: unknown): ErrorData | undefined {
if (!isRecord(input)) return undefined
if (input.name !== "AgentRequirementError" || !isRecord(input.data)) return undefined
const info = input.data
if (typeof info.message !== "string") return undefined
if (typeof info.agent !== "string") return undefined
if (typeof info.directory !== "string") return undefined
if (info.state !== "blocked" && info.state !== "error") return undefined
if (!Array.isArray(info.skills)) return undefined
if (!Array.isArray(info.mcps)) return undefined
if (!Array.isArray(info.vscode_extensions)) return undefined
return info as ErrorData
}
export function format(input: ErrorData | RequirementError) {
const info = "data" in input ? input.data : input
const skills = pending(info.skills)
const mcps = pending(info.mcps)
const lines = [`Agent requirements are not met for "${info.agent}".`]
if (info.vscode_extensions.length) {
lines.push(
"",
"VS Code extensions:",
...info.vscode_extensions.map((ext) => `- ${ext.name} (${ext.id})`),
"This agent requires VS Code extensions and is not supported in this CLI environment. Use the Kilo VS Code extension instead.",
)
}
if (skills.length) lines.push("", "Skills:", ...skills.map((skill) => `- ${item(skill)}`))
if (mcps.length) lines.push("", "MCP servers:", ...mcps.map((mcp) => `- ${item(mcp)}`))
if (skills.length || mcps.length) {
lines.push("", "Install the required skills and configure or connect the required MCP servers, then retry.")
}
return lines.join("\n")
}
function pending<T extends Item>(items: T[]) {
return items.filter((item) => item.status !== "ready")
}
function item(input: Item) {
const status = input.status === "missing" ? "missing" : "error"
return input.message ? `${input.name} (${status}: ${input.message})` : `${input.name} (${status})`
}
@@ -5,11 +5,9 @@ import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/midd
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "@/server/routes/instance/httpapi/middleware/workspace-routing"
import { described } from "@/server/routes/instance/httpapi/groups/metadata"
import { AnacondaDesktopApi } from "./anaconda-desktop"
import { Result as AgentRequirementResult } from "@/kilocode/agent-requirements"
import {
Failure as AgentManagerFailure,
Request as AgentManagerRequest,
@@ -42,10 +40,6 @@ export const RemoveAgentPayload = Schema.Struct({
scope: Schema.optional(Scope),
})
export const AgentRequirementQuery = Schema.Struct({
...WorkspaceRoutingQueryFields,
agent: Schema.String,
})
export const NotebookReplyPayload = Schema.Struct({ result: NotebookResult })
export const NotebookRejectPayload = Schema.Struct({ error: NotebookFailure })
export const AgentManagerReplyPayload = Schema.Struct({ result: AgentManagerResult })
@@ -53,7 +47,6 @@ export const AgentManagerRejectPayload = Schema.Struct({ error: AgentManagerFail
export const KilocodePaths = {
heapSnapshot: `${root}/heap/snapshot`,
agentRequirements: `${root}/agent/requirements`,
commandFiles: `${root}/command/files`,
removeCommand: `${root}/command/remove`,
removeSkill: `${root}/skill/remove`,
@@ -82,16 +75,6 @@ export const KilocodeApi = HttpApi.make("kilocode")
description: "Write a heap snapshot for the CLI process to the log directory.",
}),
),
HttpApiEndpoint.get("agentRequirements", KilocodePaths.agentRequirements, {
query: AgentRequirementQuery,
success: described(AgentRequirementResult, "Agent requirement status"),
}).annotateMerge(
OpenApi.annotations({
identifier: "kilocode.agentRequirements",
summary: "Check agent requirements",
description: "Check whether the selected agent's requirements are available in the request directory.",
}),
),
HttpApiEndpoint.get("commandFiles", KilocodePaths.commandFiles, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(CommandFiles.Info), "Command files"),
@@ -42,12 +42,6 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode"
return yield* Effect.sync(() => HeapSnapshot.write())
})
const agentRequirements = Effect.fn("KilocodeHttpApi.agentRequirements")(function* (ctx: {
query: { agent: string }
}) {
return yield* agents.requirementStatus(ctx.query.agent)
})
const commandFiles = Effect.fn("KilocodeHttpApi.commandFiles")(function* () {
const instance = yield* InstanceState.context
const dirs = yield* config.directories()
@@ -177,7 +171,6 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode"
return handlers
.handle("heapSnapshot", heapSnapshot)
.handle("agentRequirements", agentRequirements)
.handle("commandFiles", commandFiles)
.handle("removeCommand", removeCommand)
.handle("removeSkill", removeSkill)
@@ -1,7 +1,6 @@
import { Image } from "@/image/image" // kilocode_change - classify user image validation defects
import { busyMessage, isBusy } from "@/kilocode/database/sqlite-error" // kilocode_change
import { KiloSessionHttpApi } from "@/kilocode/server/httpapi/session-fork" // kilocode_change
import { BlockedError as AgentRequirementError } from "@/kilocode/agent-requirements" // kilocode_change
import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" // kilocode_change
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { KiloViewers } from "@/kilocode/presence/service" // kilocode_change
@@ -342,9 +341,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
// kilocode_change end
yield* events.publish(Session.Event.Error, {
sessionID: ctx.params.sessionID,
error: AgentRequirementError.isInstance(error)
? error.toObject()
: busy // kilocode_change
error: busy // kilocode_change
? new NamedError.Unknown({ message: busyMessage }).toObject() // kilocode_change
: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(), // kilocode_change
})
+2 -12
View File
@@ -138,18 +138,14 @@ function isOrphanedInterruptedTool(part: SessionV1.ToolPart) {
export interface Interface {
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
// kilocode_change start - prompt can fail on unmet agent requirements
readonly prompt: (
input: PromptInput,
) => Effect.Effect<SessionV1.WithParts, Image.Error | Agent.RequirementBlockedError>
// kilocode_change end
) => Effect.Effect<SessionV1.WithParts, Image.Error>
readonly loop: (input: LoopInput) => Effect.Effect<SessionV1.WithParts>
readonly shell: (input: ShellInput) => Effect.Effect<SessionV1.WithParts, Session.BusyError>
// kilocode_change start - commands can fail on unmet agent requirements or resume errors
readonly command: (
input: CommandInput,
) => Effect.Effect<SessionV1.WithParts, Image.Error | Agent.RequirementBlockedError | Error>
// kilocode_change end
) => Effect.Effect<SessionV1.WithParts, Image.Error | Error>
readonly resolvePromptParts: (template: string) => Effect.Effect<PromptInput["parts"]>
}
@@ -825,8 +821,6 @@ export const layer = Layer.effect(
yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
throw error
}
yield* agents.guardRequirements(ag) // kilocode_change - enforce requirements before creating a turn
const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID))
// kilocode_change start - retain the source session variant across Agent Manager's model-less fork handoff
const stored = !input.model && !ag.model ? model : undefined
@@ -1984,8 +1978,6 @@ export const layer = Layer.effect(
yield* events.publish(Session.Event.Error, { sessionID: input.cmdInput.sessionID, error: error.toObject() })
return yield* Effect.fail(error)
}
yield* agents.guardRequirements(agent)
// Resolve model
const model = yield* Effect.gen(function* () {
if (input.cmdInput.model) return Provider.parseModel(input.cmdInput.model)
@@ -2408,8 +2400,6 @@ export const layer = Layer.effect(
yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
throw error
}
yield* agents.guardRequirements(agent) // kilocode_change - command agent overrides must satisfy requirements
// kilocode_change start
const variant = KiloWorkflowVariant.resolve({
command: cmd,
+1 -3
View File
@@ -43,7 +43,6 @@ import { KiloSessionEvent } from "@/kilocode/session/event"
import { SessionExport } from "@/kilocode/session-export"
import * as SandboxPolicy from "@/kilocode/sandbox/policy"
import { carryForkDiff } from "@/kilocode/session-portability/cumulative-diff" // kilocode_change
import { BlockedError as AgentRequirementError } from "@/kilocode/agent-requirements"
// kilocode_change end
import { Effect, Layer, Option, Context, Schema, Types } from "effect"
import { NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
@@ -399,8 +398,7 @@ export const Event = {
sessionID: Schema.optional(SessionID),
// Reuses SessionV1.Assistant.fields.error (already Schema.optional) so
// the derived schema keeps the same discriminated-union shape on the event stream.
// kilocode_change - carry pre-message requirement failures over session.error
error: Schema.optional(Schema.Union([SessionV1.Assistant.fields.error, AgentRequirementError.EffectSchema])),
error: SessionV1.Assistant.fields.error,
},
}),
// kilocode_change start
@@ -1,5 +1,6 @@
import { test, expect, describe, afterEach, beforeEach, spyOn } from "bun:test"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { ConfigAgentV1 } from "@opencode-ai/core/v1/config/agent" // kilocode_change
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { httpClient } from "@opencode-ai/core/effect/app-node-platform"
import { Cause, Effect, Exit, Layer, Option } from "effect"
@@ -1585,6 +1586,23 @@ test("config parser preserves permission order while rejecting unknown top-level
}
})
// kilocode_change start - preserve legacy agent requirement declarations without forwarding them
test("config parser ignores legacy agent requirements", () => {
const config = ConfigParse.schema(
ConfigAgentV1.Info,
{
name: "demo",
requirements: { skills: ["needed"] },
custom: true,
},
"agent/demo.md",
)
expect(config.options).toEqual({ custom: true })
expect(config.options).not.toHaveProperty("requirements")
})
// kilocode_change end
// MCP config merging tests
// kilocode_change start - regression for `env` alias on local MCP entries
@@ -1,176 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { AgentRequirementResult } from "@kilocode/sdk/v2"
import { formatError as formatStreamError } from "@/cli/cmd/run/session-data"
import * as Requirements from "@/kilocode/cli/agent-requirements"
const dir = "/tmp/agent-requirements-cli"
function result(input: Partial<AgentRequirementResult> = {}): AgentRequirementResult {
return {
agent: "demo",
directory: dir,
enabled: true,
state: "ready",
skills: [],
mcps: [],
vscode_extensions: [],
...input,
}
}
function client(value: AgentRequirementResult): Requirements.Client {
return {
kilocode: {
agentRequirements: async (parameters, options) => {
expect(parameters).toEqual({ agent: value.agent, directory: value.directory })
expect(options).toEqual({ throwOnError: true })
return { data: value }
},
},
}
}
async function check(value: AgentRequirementResult) {
return Requirements.check({ client: client(value), agent: value.agent, directory: value.directory })
}
describe("CLI agent requirements", () => {
test("allows disabled requirements", async () => {
const output = await check(result({ enabled: false, state: "disabled" }))
expect(output.ok).toBe(true)
})
test("allows ready requirements", async () => {
const output = await check(
result({
skills: [{ name: "ready-skill", status: "ready" }],
mcps: [{ name: "ready-mcp", status: "ready" }],
}),
)
expect(output.ok).toBe(true)
})
test("blocks missing skills", async () => {
const output = await check(
result({
state: "blocked",
skills: [
{ name: "ready-skill", status: "ready" },
{ name: "missing-skill", status: "missing" },
],
}),
)
expect(output.ok).toBe(false)
if (output.ok) return
expect(output.error).toMatchObject({
name: "AgentRequirementError",
data: { agent: "demo", state: "blocked" },
})
expect(output.error.data.skills).toContainEqual({ name: "missing-skill", status: "missing" })
})
test("blocks missing and error MCPs", async () => {
const output = await check(
result({
state: "blocked",
mcps: [
{ name: "missing-mcp", status: "missing" },
{ name: "error-mcp", status: "error", message: "server crashed" },
],
}),
)
expect(output.ok).toBe(false)
if (output.ok) return
expect(output.error.data.mcps).toEqual([
{ name: "missing-mcp", status: "missing" },
{ name: "error-mcp", status: "error", message: "server crashed" },
])
})
test("blocks VS Code extension declarations", async () => {
const output = await check(
result({
vscode_extensions: [{ name: "Sample Extension", id: "publisher.extension" }],
}),
)
expect(output.ok).toBe(false)
if (output.ok) return
expect(output.error.data.state).toBe("blocked")
expect(output.error.data.vscode_extensions).toEqual([{ name: "Sample Extension", id: "publisher.extension" }])
const text = Requirements.format(output.error)
expect(text).toContain("Use the Kilo VS Code extension instead")
expect(text).not.toContain("Install the required skills and configure or connect the required MCP servers")
})
test("formats grouped actionable guidance", () => {
const text = Requirements.format(
Requirements.toError(
result({
state: "error",
error: { code: "discovery_failed", message: "skill scan failed" },
skills: [
{ name: "ready-skill", status: "ready" },
{ name: "missing-skill", status: "missing" },
{ name: "error-skill", status: "error", message: "skill scan failed" },
],
mcps: [
{ name: "missing-mcp", status: "missing" },
{ name: "error-mcp", status: "error", message: "auth failed" },
],
vscode_extensions: [{ name: "Sample Extension", id: "publisher.extension" }],
}),
),
)
expect(text).toContain('Agent requirements are not met for "demo".')
expect(text).toContain("VS Code extensions:")
expect(text).toContain("Sample Extension (publisher.extension)")
expect(text).toContain("Skills:")
expect(text).toContain("missing-skill (missing)")
expect(text).toContain("error-skill (error: skill scan failed)")
expect(text).not.toContain("ready-skill (ready)")
expect(text).toContain("MCP servers:")
expect(text).toContain("missing-mcp (missing)")
expect(text).toContain("error-mcp (error: auth failed)")
expect(text).toContain("Install the required skills and configure or connect the required MCP servers, then retry.")
})
test("recognizes AgentRequirementError objects", () => {
const error = Requirements.toError(result({ state: "blocked" }))
expect(Requirements.data(error)).toEqual(error.data)
expect(Requirements.data({ name: "OtherError", data: error.data })).toBeUndefined()
})
test("stores grouped guidance on AgentRequirementError message", () => {
const error = Requirements.toError(
result({
state: "blocked",
skills: [{ name: "missing-skill", status: "missing" }],
}),
)
const text = Requirements.format(error)
expect(text).toContain('Agent requirements are not met for "demo".')
expect(text).toContain("Skills:")
expect(text).toContain("missing-skill (missing)")
expect(error.data.message).toBe(text)
})
test("formats AgentRequirementError in interactive session errors", () => {
const error = Requirements.toError(
result({
state: "blocked",
mcps: [{ name: "missing-mcp", status: "missing" }],
}),
)
const text = Requirements.format(error)
expect(formatStreamError(error)).toBe(text)
})
})
@@ -1,307 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Exit } from "effect"
import { ConfigAgentV1 } from "@opencode-ai/core/v1/config/agent"
import { ConfigParse } from "@/config/parse"
import * as AgentRequirements from "@/kilocode/agent-requirements"
import type { MCP } from "@/mcp"
import type { Skill } from "@/skill"
type Agent = {
name: string
requirements?: unknown
}
type Input = {
active?: boolean
agents?: Record<string, Agent>
skills?: string[]
skillError?: string
mcp?: Record<string, MCP.Status>
mcpError?: string
}
const dir = "/tmp/agent-requirements"
function services(input: Input = {}) {
return {
config: {
get: () => Effect.succeed({ experimental: input.active ? { agent_requirements: true } : {} }),
},
agents: {
get: (name: string) => Effect.succeed(input.agents?.[name]),
},
skills: {
all: () => {
if (input.skillError) return Effect.die(new Error(input.skillError))
const skills = (input.skills ?? []).map(
(name) => ({ name, location: "test", content: "" }) satisfies Skill.Info,
)
return Effect.succeed(skills)
},
},
mcp: {
status: () => {
if (input.mcpError) return Effect.die(new Error(input.mcpError))
return Effect.succeed(input.mcp ?? {})
},
},
}
}
function status(name: string, input: Input) {
return Effect.runPromise(AgentRequirements.status({ ...services(input), name, directory: dir }))
}
async function client(value: string | undefined, run: () => Promise<void>) {
const prev = process.env.KILO_CLIENT
try {
if (value === undefined) delete process.env.KILO_CLIENT
if (value !== undefined) process.env.KILO_CLIENT = value
await run()
} finally {
if (prev === undefined) delete process.env.KILO_CLIENT
if (prev !== undefined) process.env.KILO_CLIENT = prev
}
}
describe("agent requirements", () => {
test("returns disabled when the experimental flag is absent", async () => {
const result = await status("missing", {})
expect(result).toMatchObject({
agent: "missing",
directory: dir,
enabled: false,
state: "disabled",
skills: [],
mcps: [],
vscode_extensions: [],
})
})
test("returns ready for agents without requirements", async () => {
const result = await status("demo", { active: true, agents: { demo: { name: "demo" } } })
expect(result).toMatchObject({ agent: "demo", directory: dir, enabled: true, state: "ready" })
})
test("reports unknown agents while requirements are enabled", async () => {
const result = await status("missing", { active: true })
expect(result.state).toBe("error")
expect(result.error?.code).toBe("unknown_agent")
})
test("reports discovered and missing skills", async () => {
const result = await status("demo", {
active: true,
agents: { demo: { name: "demo", requirements: { skills: ["ready", "absent"] } } },
skills: ["ready"],
})
expect(result.state).toBe("blocked")
expect(result.skills).toEqual([
{ name: "ready", status: "ready" },
{ name: "absent", status: "missing" },
])
})
test("accepts non-empty marketplace skill and MCP IDs", async () => {
const result = await status("demo", {
active: true,
agents: {
demo: {
name: "demo",
requirements: { skills: ["skill with space"], mcps: ["mcp/with/slash"] },
},
},
skills: ["skill with space"],
mcp: { "mcp/with/slash": { status: "connected" } },
})
expect(result.state).toBe("ready")
expect(result.skills).toEqual([{ name: "skill with space", status: "ready" }])
expect(result.mcps).toEqual([{ name: "mcp/with/slash", status: "ready" }])
})
test("reports skill discovery failures as errors", async () => {
const result = await status("demo", {
active: true,
agents: { demo: { name: "demo", requirements: { skills: ["needed"] } } },
skillError: "skill scan failed",
})
expect(result.state).toBe("error")
expect(result.error?.code).toBe("discovery_failed")
expect(result.skills[0]?.status).toBe("error")
expect(result.skills[0]?.message).toContain("skill scan failed")
})
test("reports malformed and duplicate declarations", async () => {
const empty = await status("empty", {
active: true,
agents: { empty: { name: "empty", requirements: {} } },
})
const duplicate = await status("duplicate", {
active: true,
agents: { duplicate: { name: "duplicate", requirements: { skills: ["one", "one"] } } },
})
const legacy = await status("legacy", {
active: true,
agents: { legacy: { name: "legacy", requirements: { vscode_extensions: ["publisher.extension"] } } },
})
expect(empty.error?.code).toBe("malformed_declaration")
expect(duplicate.error?.code).toBe("malformed_declaration")
expect(legacy.error?.code).toBe("malformed_declaration")
})
test("validates VS Code extension objects", async () => {
const valid = await status("valid", {
active: true,
agents: {
valid: {
name: "valid",
requirements: { vscode_extensions: [{ name: "Sample Extension", id: "publisher.extension" }] },
},
},
})
const invalid = await status("invalid", {
active: true,
agents: {
invalid: {
name: "invalid",
requirements: { vscode_extensions: [{ name: " ", id: "publisher.extension" }] },
},
},
})
expect(valid).toMatchObject({
state: "ready",
vscode_extensions: [{ name: "Sample Extension", id: "publisher.extension" }],
})
expect(invalid.state).toBe("error")
expect(invalid.error?.code).toBe("malformed_declaration")
})
test("reports MCP connected, missing, and error states", async () => {
const result = await status("demo", {
active: true,
agents: {
demo: {
name: "demo",
requirements: { mcps: ["connected", "disabled", "failed", "registration"] },
},
},
mcp: {
connected: { status: "connected" },
disabled: { status: "disabled" },
failed: { status: "failed", error: "server crashed" },
registration: { status: "needs_client_registration", error: "OAuth required" },
},
})
expect(result.state).toBe("blocked")
expect(result.mcps).toEqual([
{ name: "connected", status: "ready" },
{ name: "disabled", status: "missing" },
{ name: "failed", status: "error", message: "server crashed" },
{ name: "registration", status: "error", message: "OAuth required" },
])
})
test("reports MCP status service failures", async () => {
const result = await status("demo", {
active: true,
agents: { demo: { name: "demo", requirements: { mcps: ["needed"] } } },
mcpError: "status unavailable",
})
expect(result.state).toBe("error")
expect(result.error?.code).toBe("mcp_status_failed")
expect(result.mcps[0]?.status).toBe("error")
expect(result.mcps[0]?.message).toContain("status unavailable")
})
test("guards unmet requirements for all clients", async () => {
const missing = {
active: true,
agents: { demo: { name: "demo", requirements: { skills: ["missing"] } } },
}
await client("cli", async () => {
const exit = await Effect.runPromiseExit(
AgentRequirements.guard({ ...services(missing), agent: missing.agents.demo, directory: dir }),
)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isSuccess(exit)) return
const error = Cause.squash(exit.cause)
expect(AgentRequirements.BlockedError.isInstance(error)).toBe(true)
})
await client("vscode", async () => {
const exit = await Effect.runPromiseExit(
AgentRequirements.guard({ ...services(missing), agent: missing.agents.demo, directory: dir }),
)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isSuccess(exit)) return
const error = Cause.squash(exit.cause)
expect(AgentRequirements.BlockedError.isInstance(error)).toBe(true)
})
})
test("blocks VS Code extension requirements outside VS Code", async () => {
const input = {
active: true,
agents: {
demo: {
name: "demo",
requirements: { vscode_extensions: [{ name: "Jupyter", id: "ms-toolsai.jupyter" }] },
},
},
}
await client("cli", async () => {
const exit = await Effect.runPromiseExit(
AgentRequirements.guard({ ...services(input), agent: input.agents.demo, directory: dir }),
)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isSuccess(exit)) return
const error = Cause.squash(exit.cause)
expect(AgentRequirements.BlockedError.isInstance(error)).toBe(true)
})
await client("vscode", async () => {
await Effect.runPromise(AgentRequirements.guard({ ...services(input), agent: input.agents.demo, directory: dir }))
})
})
test("allows non-VS Code clients when requirements are ready", async () => {
const input = {
active: true,
agents: { demo: { name: "demo", requirements: { skills: ["ready"], mcps: ["connected"] } } },
skills: ["ready"],
mcp: { connected: { status: "connected" as const } },
}
await client("cli", async () => {
await Effect.runPromise(AgentRequirements.guard({ ...services(input), agent: input.agents.demo, directory: dir }))
})
})
test("keeps requirements out of agent options", () => {
const agent = ConfigParse.schema(
ConfigAgentV1.Info,
{
name: "demo",
requirements: { skills: ["needed"] },
custom: true,
},
"agent/demo.md",
)
expect(agent.requirements).toEqual({ skills: ["needed"] })
expect(agent.options).toEqual({ custom: true })
expect(agent.options).not.toHaveProperty("requirements")
})
})
@@ -556,19 +556,6 @@ export const kiloScenarios: Scenario[] = [
yield* Effect.promise(() => rm(body, { force: true }))
}),
),
http.protected
.get("/kilocode/agent/requirements", "kilocode.agentRequirements")
.at((ctx) => ({ path: "/kilocode/agent/requirements?agent=httpapi-agent", headers: ctx.headers() }))
.json(200, (body, ctx) => {
object(body)
check(body.agent === "httpapi-agent", "agent requirements should echo the requested agent")
check(body.directory === ctx.directory, "agent requirements should use the routed workspace directory")
check(body.enabled === false, "agent requirements should report disabled when the experiment is off")
check(body.state === "disabled", "agent requirements should return the disabled state")
array(body.skills)
array(body.mcps)
array(body.vscode_extensions)
}),
http.protected
.get("/kilocode/command/files", "kilocode.commandFiles")
.inProject({ git: true, init: command })
@@ -74,7 +74,6 @@ const fastAgents = Layer.mock(AgentSvc.Service)({
list: () => Effect.succeed([agent]),
defaultInfo: () => Effect.succeed(agent),
defaultAgent: () => Effect.succeed(agent.name),
guardRequirements: () => Effect.void,
})
const summary = Layer.succeed(
@@ -16,17 +16,6 @@ const agents = Agent.Service.of({
list: () => Effect.succeed([info]),
defaultInfo: () => Effect.succeed(info),
defaultAgent: () => Effect.succeed("code"),
requirementStatus: () =>
Effect.succeed({
agent: "code",
directory: "",
enabled: false,
state: "ready",
skills: [],
mcps: [],
vscode_extensions: [],
}),
guardRequirements: () => Effect.void,
generate: () => Effect.succeed({ identifier: "code", whenToUse: "", systemPrompt: "" }),
})
@@ -20,17 +20,6 @@ const agents = Agent.Service.of({
list: () => Effect.succeed([agentInfo]),
defaultInfo: () => Effect.succeed(agentInfo),
defaultAgent: () => Effect.succeed("code"),
requirementStatus: () =>
Effect.succeed({
agent: "code",
directory: "",
enabled: false,
state: "ready",
skills: [],
mcps: [],
vscode_extensions: [],
}),
guardRequirements: () => Effect.void,
generate: () => Effect.succeed({ identifier: "code", whenToUse: "", systemPrompt: "" }),
})
@@ -26,17 +26,6 @@ const agents = Agent.Service.of({
list: () => Effect.succeed([agentInfo]),
defaultInfo: () => Effect.succeed(agentInfo),
defaultAgent: () => Effect.succeed("code"),
requirementStatus: () =>
Effect.succeed({
agent: "code",
directory: "",
enabled: false,
state: "ready",
skills: [],
mcps: [],
vscode_extensions: [],
}),
guardRequirements: () => Effect.void,
generate: () => Effect.succeed({ identifier: "code", whenToUse: "", systemPrompt: "" }),
})
@@ -189,7 +189,6 @@ const fastAgents = Layer.mock(AgentSvc.Service)({
list: () => Effect.succeed([agent]),
defaultInfo: () => Effect.succeed(agent),
defaultAgent: () => Effect.succeed(agent.name),
guardRequirements: () => Effect.void,
})
const processorCreateStarted: Deferred.Deferred<void>[] = []