mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge remote-tracking branch 'origin/johnnyeric/kilo-opencode-v1.18.0' into johnnyeric/kilo-opencode-v1.18.13
This commit is contained in:
@@ -834,11 +834,7 @@ export class RunFooter implements FooterApi {
|
||||
return
|
||||
}
|
||||
|
||||
const previous = this.currentModel()
|
||||
this.setCurrentModel(model)
|
||||
if (!previous || previous.providerID !== model.providerID || previous.modelID !== model.modelID) {
|
||||
this.setCurrentVariant(undefined)
|
||||
}
|
||||
void Promise.resolve()
|
||||
.then(() => this.options.onModelSelect?.(model))
|
||||
.then((result) => {
|
||||
|
||||
@@ -21,6 +21,8 @@ import { resolveModelInfo, resolveRunTuiConfig, resolveSessionInfo } from "./run
|
||||
import { createRuntimeLifecycle } from "./runtime.lifecycle"
|
||||
import { trace } from "./trace"
|
||||
import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared"
|
||||
// kilocode_change - preserve compatible variants when switching models
|
||||
import { resolvePreservedVariant } from "@/kilocode/cli/cmd/run/variant" // kilocode_change
|
||||
import type { LocalReplayAnchor, LocalReplayRow, RunInput, RunPrompt, RunProvider, StreamCommit } from "./types"
|
||||
|
||||
/** @internal Exported for testing */
|
||||
@@ -294,8 +296,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
return
|
||||
}
|
||||
|
||||
// kilocode_change start - preserve the active effort across model switches
|
||||
const previous = state.activeVariant
|
||||
state.model = model
|
||||
state.activeVariant = undefined
|
||||
state.variants = variantsFor(state.providers, model)
|
||||
const switching = resolveSavedVariant(model).then((saved) => {
|
||||
const current = state.model
|
||||
@@ -303,8 +306,12 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
return
|
||||
}
|
||||
|
||||
state.activeVariant = resolveVariant(ctx.variant, undefined, saved, state.variants)
|
||||
// kilocode_change - prefer the active effort over a model-specific saved preference
|
||||
state.activeVariant =
|
||||
resolvePreservedVariant(ctx.variant, previous, state.variants) ??
|
||||
resolveVariant(ctx.variant, undefined, saved, state.variants)
|
||||
})
|
||||
// kilocode_change end
|
||||
state.switching = switching
|
||||
await switching
|
||||
if (state.switching === switching) {
|
||||
@@ -440,10 +447,14 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
state.variants = variantsFor(state.providers, state.model)
|
||||
state.limits = info.limits
|
||||
|
||||
const next = resolveVariant(ctx.variant, session.variant, savedVariant, state.variants)
|
||||
// kilocode_change start - preserve the active effort when the model catalog arrives asynchronously
|
||||
const next =
|
||||
resolvePreservedVariant(ctx.variant, state.activeVariant, state.variants) ??
|
||||
resolveVariant(ctx.variant, session.variant, savedVariant, state.variants)
|
||||
if (next !== state.activeVariant) {
|
||||
state.activeVariant = next
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
if (footer.isClosed) {
|
||||
return
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import open from "open"
|
||||
|
||||
export const WebCommand = effectCmd({
|
||||
command: "web",
|
||||
builder: (yargs) => withNetworkOptions(yargs),
|
||||
describe: "start kilo server and open web interface",
|
||||
// Server loads instances per-request via x-kilo-directory header — no
|
||||
// ambient project InstanceContext needed at startup.
|
||||
instance: false, // kilocode_change
|
||||
handler: Effect.fn("Cli.web")(function* (args) {
|
||||
const { Server } = yield* Effect.promise(() => import("../../server/server"))
|
||||
if (!Flag.KILO_SERVER_PASSWORD) {
|
||||
UI.println(UI.Style.TEXT_WARNING_BOLD + "! KILO_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = yield* resolveNetworkOptions(args)
|
||||
const server = yield* Effect.promise(() => Server.listen(opts))
|
||||
UI.empty()
|
||||
UI.println(UI.logo(" "))
|
||||
UI.empty()
|
||||
|
||||
// kilocode_change start
|
||||
const urls = server.urls
|
||||
|
||||
UI.println(UI.Style.TEXT_INFO_BOLD + " Local: ", UI.Style.TEXT_NORMAL, urls.local)
|
||||
if (urls.network) {
|
||||
UI.println(UI.Style.TEXT_INFO_BOLD + " Network: ", UI.Style.TEXT_NORMAL, urls.network)
|
||||
}
|
||||
|
||||
if (opts.mdns) {
|
||||
UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, `${opts.mdnsDomain}:${server.port}`)
|
||||
}
|
||||
|
||||
open(urls.local).catch(() => {})
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start - graceful signal shutdown
|
||||
const { InstanceRuntime } = yield* Effect.promise(() => import("../../project/instance-runtime"))
|
||||
yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
const shutdown = async () => {
|
||||
try {
|
||||
await InstanceRuntime.disposeAllInstances()
|
||||
await server.stop(true)
|
||||
} finally {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
process.once("SIGTERM", shutdown)
|
||||
process.once("SIGINT", shutdown)
|
||||
process.once("SIGHUP", shutdown)
|
||||
}),
|
||||
)
|
||||
// kilocode_change end
|
||||
}),
|
||||
})
|
||||
@@ -22,7 +22,7 @@ import { AttachCommand } from "./cli/cmd/attach"
|
||||
import { TuiThreadCommand } from "./cli/cmd/tui"
|
||||
import { AcpCommand } from "./cli/cmd/acp"
|
||||
import { EOL } from "os"
|
||||
import { WebCommand } from "./cli/cmd/web"
|
||||
// kilocode_change - upstream web command intentionally omitted; Kilo does not ship an embedded web UI
|
||||
import { PrCommand } from "./cli/cmd/pr"
|
||||
import { SessionCommand } from "./cli/cmd/session"
|
||||
import { DbCommand } from "./cli/cmd/db"
|
||||
@@ -106,7 +106,7 @@ let cli = yargs(args) // kilocode_change
|
||||
.command(UpgradeCommand)
|
||||
.command(UninstallCommand)
|
||||
.command(ServeCommand)
|
||||
.command(WebCommand)
|
||||
// kilocode_change - upstream web command intentionally omitted
|
||||
.command(ModelsCommand)
|
||||
.command(StatsCommand)
|
||||
.command(ExportCommand)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
const effort = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
|
||||
|
||||
/** Keep an explicit CLI variant verbatim; only infer a fallback for automatic selections. */
|
||||
export function resolvePreservedVariant(
|
||||
input: string | undefined,
|
||||
current: string | undefined,
|
||||
variants: string[],
|
||||
): string | undefined {
|
||||
return input ?? preserveVariant(current, variants)
|
||||
}
|
||||
|
||||
/** Keep the selected effort when possible, falling back to the nearest known effort. */
|
||||
export function preserveVariant(current: string | undefined, variants: string[]): string | undefined {
|
||||
if (!current || variants.length === 0) return undefined
|
||||
if (variants.includes(current)) return current
|
||||
|
||||
const rank = effort.indexOf(current)
|
||||
if (rank === -1) return undefined
|
||||
|
||||
return variants
|
||||
.map((value, index) => ({
|
||||
value,
|
||||
index,
|
||||
rank: effort.indexOf(value),
|
||||
distance: Math.abs(effort.indexOf(value) - rank),
|
||||
}))
|
||||
.filter((item) => effort.includes(item.value))
|
||||
.sort((a, b) => a.distance - b.distance || b.rank - a.rank || a.index - b.index)[0]?.value
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { readFile, unlink } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Glob } from "@opencode-ai/core/util/glob"
|
||||
import { Schema } from "effect"
|
||||
import { Command } from "@/command"
|
||||
import { configEntryNameFromPath } from "@/config/entry-name"
|
||||
import { WorkflowsMigrator } from "@/kilocode/workflows-migrator"
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.optional(Schema.String),
|
||||
agent: Schema.optional(Schema.String),
|
||||
model: Schema.optional(Schema.String),
|
||||
variant: Schema.optional(Schema.String),
|
||||
source: Schema.optional(Schema.String),
|
||||
builtin: Schema.Boolean,
|
||||
location: Schema.String,
|
||||
editable: Schema.Boolean,
|
||||
content: Schema.optional(Schema.String),
|
||||
subtask: Schema.optional(Schema.Boolean),
|
||||
hints: Schema.Array(Schema.String),
|
||||
}).annotate({ identifier: "CommandFile" })
|
||||
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
type File = {
|
||||
name: string
|
||||
location: string
|
||||
content: string
|
||||
}
|
||||
|
||||
const COMMAND_PREFIXES = ["command/", "commands/"]
|
||||
|
||||
async function files(dir: string) {
|
||||
const result: File[] = []
|
||||
for (const file of await Glob.scan("{command,commands}/**/*.md", { cwd: dir, absolute: true, dot: true, symlink: true })) {
|
||||
result.push(await command(dir, file))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function command(dir: string, file: string): Promise<File> {
|
||||
const content = await readFile(file, "utf8")
|
||||
return {
|
||||
name: configEntryNameFromPath(path.relative(dir, file), COMMAND_PREFIXES),
|
||||
location: file,
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
function precedence(files: File[]) {
|
||||
const result = new Map<string, File>()
|
||||
for (const file of files) result.set(file.name, file)
|
||||
return result
|
||||
}
|
||||
|
||||
function description(cmd: Command.Info, file?: File) {
|
||||
if (cmd.description) return cmd.description
|
||||
if (file) return WorkflowsMigrator.extractDescription(file.content)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function literal(cmd: Command.Info) {
|
||||
return typeof cmd.template === "string" ? cmd.template : undefined
|
||||
}
|
||||
|
||||
export async function discover(input: { commands: readonly Command.Info[]; directories: readonly string[]; directory: string }) {
|
||||
const all = []
|
||||
for (const item of await WorkflowsMigrator.discoverWorkflows(input.directory)) {
|
||||
all.push({ name: item.name, location: item.path, content: item.content })
|
||||
}
|
||||
for (const dir of input.directories) all.push(...(await files(dir)))
|
||||
const by = precedence(all)
|
||||
return input.commands
|
||||
.filter((cmd) => cmd.source !== "skill")
|
||||
.map((cmd): Info => {
|
||||
const file = by.get(cmd.name)
|
||||
if (file) {
|
||||
return {
|
||||
name: cmd.name,
|
||||
description: description(cmd, file),
|
||||
agent: cmd.agent,
|
||||
model: cmd.model,
|
||||
variant: cmd.variant,
|
||||
source: cmd.source,
|
||||
builtin: false,
|
||||
location: file.location,
|
||||
editable: true,
|
||||
content: file.content,
|
||||
subtask: cmd.subtask,
|
||||
hints: cmd.hints,
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: cmd.name,
|
||||
description: description(cmd),
|
||||
agent: cmd.agent,
|
||||
model: cmd.model,
|
||||
variant: cmd.variant,
|
||||
source: cmd.source,
|
||||
builtin: true,
|
||||
location: "builtin",
|
||||
editable: false,
|
||||
content: literal(cmd),
|
||||
subtask: cmd.subtask,
|
||||
hints: cmd.hints,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function target(location: string, commands: readonly Info[]) {
|
||||
if (!path.isAbsolute(location)) throw new Error("command location must be absolute")
|
||||
const file = path.resolve(location)
|
||||
const command = commands.find((item) => item.editable && path.resolve(item.location) === file)
|
||||
if (!command) throw new Error("command not found in registry")
|
||||
if (!file.endsWith(".md")) throw new Error("command location must reference a markdown file")
|
||||
const cache = path.join(Global.Path.cache, "commands")
|
||||
const relative = path.relative(cache, file)
|
||||
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) {
|
||||
throw new Error("remove cache-backed commands from configuration")
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
export async function remove(location: string, commands: readonly Info[]) {
|
||||
await unlink(target(location, commands))
|
||||
}
|
||||
|
||||
export * as CommandFiles from "./command-files"
|
||||
@@ -13,7 +13,6 @@ import { AgentCommand } from "../cli/cmd/agent"
|
||||
import { UpgradeCommand } from "../cli/cmd/upgrade"
|
||||
import { UninstallCommand } from "../cli/cmd/uninstall"
|
||||
import { ServeCommand } from "../cli/cmd/serve"
|
||||
import { WebCommand } from "../cli/cmd/web"
|
||||
import { ModelsCommand } from "../cli/cmd/models"
|
||||
import { StatsCommand } from "../cli/cmd/stats"
|
||||
import { ExportCommand } from "../cli/cmd/export"
|
||||
@@ -61,7 +60,6 @@ export const commands = [
|
||||
UpgradeCommand,
|
||||
UninstallCommand,
|
||||
ServeCommand,
|
||||
WebCommand,
|
||||
ModelsCommand,
|
||||
RollCallCommand,
|
||||
ProfileCommand,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "@/kilocode/notebook/protocol"
|
||||
import { ModelUsage } from "@/kilocode/session/model-usage"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { CommandFiles } from "@/kilocode/command-files"
|
||||
|
||||
const root = "/kilocode"
|
||||
|
||||
@@ -31,6 +32,10 @@ export const RemoveSkillPayload = Schema.Struct({
|
||||
location: Schema.String,
|
||||
})
|
||||
|
||||
export const RemoveCommandPayload = Schema.Struct({
|
||||
location: Schema.String,
|
||||
})
|
||||
|
||||
export const RemoveAgentPayload = Schema.Struct({
|
||||
name: Schema.String,
|
||||
})
|
||||
@@ -47,6 +52,8 @@ 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`,
|
||||
removeAgent: `${root}/agent/remove`,
|
||||
notebookList: `${root}/notebook`,
|
||||
@@ -83,6 +90,28 @@ export const KilocodeApi = HttpApi.make("kilocode")
|
||||
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"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "kilocode.commandFiles",
|
||||
summary: "List command files",
|
||||
description: "List commands with editable file locations for settings clients.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("removeCommand", KilocodePaths.removeCommand, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: RemoveCommandPayload,
|
||||
success: described(Schema.Boolean, "Command removed"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "kilocode.removeCommand",
|
||||
summary: "Remove a command",
|
||||
description: "Remove a command by deleting its markdown file from disk and clearing it from cache.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("removeSkill", KilocodePaths.removeSkill, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: RemoveSkillPayload,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
||||
import * as KiloAgent from "@/kilocode/agent"
|
||||
import { CommandFiles } from "@/kilocode/command-files"
|
||||
import * as KiloSkill from "@/kilocode/skill-remove"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Command } from "@/command"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { HeapSnapshot } from "@/kilocode/cli/heap-snapshot"
|
||||
@@ -21,12 +23,14 @@ import {
|
||||
NotebookRejectPayload,
|
||||
NotebookReplyPayload,
|
||||
RemoveAgentPayload,
|
||||
RemoveCommandPayload,
|
||||
RemoveSkillPayload,
|
||||
} from "../groups/kilocode"
|
||||
|
||||
export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
const commands = yield* Command.Service
|
||||
const skills = yield* Skill.Service
|
||||
const config = yield* Config.Service
|
||||
const store = yield* InstanceStore.Service
|
||||
@@ -43,6 +47,34 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode"
|
||||
return yield* agents.requirementStatus(ctx.query.agent)
|
||||
})
|
||||
|
||||
const commandFiles = Effect.fn("KilocodeHttpApi.commandFiles")(function* () {
|
||||
const instance = yield* InstanceState.context
|
||||
const dirs = yield* config.directories()
|
||||
const items = yield* commands.list()
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => CommandFiles.discover({ commands: items, directories: dirs, directory: instance.directory }),
|
||||
catch: (err) => err,
|
||||
}).pipe(Effect.catch((err) => Effect.die(err)))
|
||||
})
|
||||
|
||||
const removeCommand = Effect.fn("KilocodeHttpApi.removeCommand")(function* (ctx: {
|
||||
payload: typeof RemoveCommandPayload.Type
|
||||
}) {
|
||||
const instance = yield* InstanceState.context
|
||||
const dirs = yield* config.directories()
|
||||
const items = yield* commands.list()
|
||||
const entries = yield* Effect.tryPromise({
|
||||
try: () => CommandFiles.discover({ commands: items, directories: dirs, directory: instance.directory }),
|
||||
catch: (err) => err,
|
||||
}).pipe(Effect.catch((err) => Effect.die(err)))
|
||||
yield* Effect.tryPromise({
|
||||
try: () => CommandFiles.remove(ctx.payload.location, entries),
|
||||
catch: () => new HttpApiError.BadRequest({}),
|
||||
})
|
||||
yield* store.dispose(instance)
|
||||
return true
|
||||
})
|
||||
|
||||
const removeSkill = Effect.fn("KilocodeHttpApi.removeSkill")(function* (ctx: {
|
||||
payload: typeof RemoveSkillPayload.Type
|
||||
}) {
|
||||
@@ -136,6 +168,8 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode"
|
||||
return handlers
|
||||
.handle("heapSnapshot", heapSnapshot)
|
||||
.handle("agentRequirements", agentRequirements)
|
||||
.handle("commandFiles", commandFiles)
|
||||
.handle("removeCommand", removeCommand)
|
||||
.handle("removeSkill", removeSkill)
|
||||
.handle("removeAgent", removeAgent)
|
||||
.handle("notebookList", notebookList)
|
||||
|
||||
@@ -117,7 +117,7 @@ const WireParams = Schema.Struct({
|
||||
),
|
||||
filter: Schema.optional(ListParams.fields.filter),
|
||||
sessionID: Schema.optional(
|
||||
SessionID.annotate({ description: "For move, use a session ID returned by action=list." }),
|
||||
Schema.String.annotate({ description: "For move, use a session ID returned by action=list (IDs start with ses_)." }),
|
||||
),
|
||||
prompt: Schema.optional(PromptParams.fields.prompt),
|
||||
sectionID: Schema.optional(MoveParams.fields.sectionID),
|
||||
|
||||
@@ -235,27 +235,6 @@ Options:
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Kilo CLI help-text snapshots every documented command emits stable help text: kilo web --help 1`] = `
|
||||
"kilo web
|
||||
|
||||
start kilo server and open web interface
|
||||
|
||||
Options:
|
||||
-h, --help show help [boolean]
|
||||
-v, --version show version number [boolean]
|
||||
--print-logs print logs to stderr [boolean]
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]
|
||||
--port port to listen on [number] [default: 0]
|
||||
--hostname hostname to listen on [string] [default: "127.0.0.1"]
|
||||
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0)
|
||||
[boolean] [default: false]
|
||||
--mdns-domain custom domain name for mDNS service (default: kilo.local)
|
||||
[string] [default: "kilo.local"]
|
||||
--cors additional domains to allow for CORS [array] [default: []]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Kilo CLI help-text snapshots every documented command emits stable help text: kilo models --help 1`] = `
|
||||
"kilo models [provider]
|
||||
|
||||
|
||||
@@ -66,7 +66,6 @@ const TOP_LEVEL = [
|
||||
"upgrade",
|
||||
"uninstall",
|
||||
"serve",
|
||||
"web",
|
||||
"models",
|
||||
"stats",
|
||||
"export",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, ManagedRuntime, Queue } from "effect"
|
||||
import { Effect, Layer, ManagedRuntime, Queue, Schema } from "effect"
|
||||
import { MessageID, SessionID } from "../../src/session/schema"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AgentManagerTool } from "../../src/kilocode/tool/agent-manager"
|
||||
import { AgentManagerTool, Params } from "../../src/kilocode/tool/agent-manager"
|
||||
import { AgentManagerEvent, type AgentManagerStart } from "../../src/kilocode/agent-manager/event"
|
||||
import { AgentManager } from "../../src/kilocode/agent-manager/service"
|
||||
import { Bus } from "../../src/bus"
|
||||
@@ -164,8 +164,9 @@ describe("agent_manager tool", () => {
|
||||
expect(action && typeof action === "object" ? action.description : undefined).toContain("Use list first")
|
||||
expect(action && typeof action === "object" ? action.description : undefined).toContain("Never edit")
|
||||
expect(schema.properties?.sessionID).toEqual(
|
||||
expect.objectContaining({ description: expect.stringContaining("returned by action=list") }),
|
||||
expect.objectContaining({ description: expect.stringContaining("IDs start with ses_") }),
|
||||
)
|
||||
expect(schema.properties?.sessionID).not.toHaveProperty("pattern")
|
||||
expect(schema.properties?.sectionID).toEqual(
|
||||
expect.objectContaining({ description: expect.stringContaining("Use null to unassign") }),
|
||||
)
|
||||
@@ -186,6 +187,11 @@ describe("agent_manager tool", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps session ID validation local", () => {
|
||||
expect(Schema.is(Params)({ action: "stop", sessionID: "ses_target" })).toBe(true)
|
||||
expect(Schema.is(Params)({ action: "stop", sessionID: "invalid" })).toBe(false)
|
||||
})
|
||||
|
||||
test("asks for agent_manager permission", async () => {
|
||||
const tool = await init()
|
||||
const calls: unknown[] = []
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { preserveVariant, resolvePreservedVariant } from "@/kilocode/cli/cmd/run/variant"
|
||||
|
||||
describe("Kilo CLI variant preservation", () => {
|
||||
test("keeps exact variants across supported families", () => {
|
||||
expect(preserveVariant("high", ["low", "high"])).toBe("high")
|
||||
expect(preserveVariant("thinking", ["instant", "thinking"])).toBe("thinking")
|
||||
expect(preserveVariant("default", ["default", "thinking"])).toBe("default")
|
||||
})
|
||||
|
||||
test("keeps an explicit CLI variant verbatim", () => {
|
||||
expect(resolvePreservedVariant("max", "high", ["low", "medium", "high"])).toBe("max")
|
||||
expect(resolvePreservedVariant("thinking", "high", ["low", "medium", "high"])).toBe("thinking")
|
||||
})
|
||||
|
||||
test("falls back to the nearest supported reasoning effort", () => {
|
||||
expect(preserveVariant("max", ["high", "xhigh"])).toBe("xhigh")
|
||||
expect(preserveVariant("high", ["low", "medium"])).toBe("medium")
|
||||
expect(preserveVariant("max", ["none", "low"])).toBe("low")
|
||||
})
|
||||
|
||||
test("does not cross binary or custom variant families", () => {
|
||||
expect(preserveVariant("thinking", ["low", "high"])).toBeUndefined()
|
||||
expect(preserveVariant("instant", ["low", "high"])).toBeUndefined()
|
||||
expect(preserveVariant("turbo", ["low", "high"])).toBeUndefined()
|
||||
expect(preserveVariant("high", ["instant", "thinking"])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { CommandFiles } from "../../src/kilocode/command-files"
|
||||
import type { Command } from "../../src/command"
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.map((dir) => rm(dir, { recursive: true, force: true })))
|
||||
roots.length = 0
|
||||
})
|
||||
|
||||
async function temp() {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "kilo-command-files-"))
|
||||
roots.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
function cmd(input: Partial<Command.Info> & Pick<Command.Info, "name">): Command.Info {
|
||||
return {
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
variant: input.variant,
|
||||
source: input.source,
|
||||
template: input.template ?? "body",
|
||||
subtask: input.subtask,
|
||||
hints: input.hints ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
describe("CommandFiles", () => {
|
||||
test("discovers editable command files and read-only builtins", async () => {
|
||||
const dir = await temp()
|
||||
const file = path.join(dir, ".kilo", "command", "review.md")
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
await writeFile(file, "---\ndescription: Review code\n---\n\nReview $ARGUMENTS")
|
||||
|
||||
const items = await CommandFiles.discover({
|
||||
directory: dir,
|
||||
directories: [path.join(dir, ".kilo")],
|
||||
commands: [
|
||||
cmd({
|
||||
name: "review",
|
||||
source: "command",
|
||||
agent: "reviewer",
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
variant: "high",
|
||||
subtask: true,
|
||||
hints: ["$ARGUMENTS"],
|
||||
}),
|
||||
cmd({ name: "init", source: "command" }),
|
||||
],
|
||||
})
|
||||
|
||||
expect(items.map((item) => item.name)).toEqual(["review", "init"])
|
||||
expect(items[0]).toMatchObject({
|
||||
name: "review",
|
||||
editable: true,
|
||||
builtin: false,
|
||||
location: file,
|
||||
agent: "reviewer",
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
variant: "high",
|
||||
subtask: true,
|
||||
})
|
||||
expect(items[0].content).toContain("Review $ARGUMENTS")
|
||||
expect(items[1]).toMatchObject({ name: "init", editable: false, builtin: true, location: "builtin" })
|
||||
})
|
||||
|
||||
test("maps legacy workflows to editable commands", async () => {
|
||||
const dir = await temp()
|
||||
const file = path.join(dir, ".kilo", "workflows", "ship.md")
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
await writeFile(file, "# Ship\n\nRun release checks")
|
||||
|
||||
const items = await CommandFiles.discover({
|
||||
directory: dir,
|
||||
directories: [path.join(dir, ".kilo")],
|
||||
commands: [cmd({ name: "ship", source: "command", description: "Workflow: ship" })],
|
||||
})
|
||||
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toMatchObject({ name: "ship", editable: true, builtin: false, location: file })
|
||||
expect(items[0].content).toBe("# Ship\n\nRun release checks")
|
||||
})
|
||||
|
||||
test("prefers command file attribution over same-named legacy workflow", async () => {
|
||||
const dir = await temp()
|
||||
const workflow = path.join(dir, ".kilo", "workflows", "ship.md")
|
||||
const file = path.join(dir, ".kilo", "command", "ship.md")
|
||||
await mkdir(path.dirname(workflow), { recursive: true })
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
await writeFile(workflow, "# Legacy Ship")
|
||||
await writeFile(file, "# Command Ship")
|
||||
|
||||
const items = await CommandFiles.discover({
|
||||
directory: dir,
|
||||
directories: [path.join(dir, ".kilo")],
|
||||
commands: [cmd({ name: "ship", source: "command" })],
|
||||
})
|
||||
|
||||
expect(items[0]).toMatchObject({ name: "ship", editable: true, builtin: false, location: file })
|
||||
expect(items[0].content).toBe("# Command Ship")
|
||||
})
|
||||
|
||||
test("discovers symlinked command files", async () => {
|
||||
const dir = await temp()
|
||||
const real = path.join(dir, "linked", "review.md")
|
||||
const link = path.join(dir, ".kilo", "command", "review.md")
|
||||
await mkdir(path.dirname(real), { recursive: true })
|
||||
await mkdir(path.dirname(link), { recursive: true })
|
||||
await writeFile(real, "Review from symlink")
|
||||
await symlink(real, link)
|
||||
|
||||
const items = await CommandFiles.discover({
|
||||
directory: dir,
|
||||
directories: [path.join(dir, ".kilo")],
|
||||
commands: [cmd({ name: "review", source: "command" })],
|
||||
})
|
||||
|
||||
expect(items[0]).toMatchObject({ name: "review", editable: true, builtin: false, location: link })
|
||||
expect(items[0].content).toBe("Review from symlink")
|
||||
})
|
||||
|
||||
test("remove only accepts known editable markdown files", async () => {
|
||||
const dir = await temp()
|
||||
const file = path.join(dir, ".kilo", "command", "ok.md")
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
await writeFile(file, "OK")
|
||||
const entries = [
|
||||
{ name: "ok", location: file, editable: true, builtin: false, hints: [] },
|
||||
{ name: "init", location: "builtin", editable: false, builtin: true, hints: [] },
|
||||
]
|
||||
|
||||
await expect(CommandFiles.remove("builtin", entries)).rejects.toThrow("absolute")
|
||||
await expect(CommandFiles.remove(path.join(dir, "other.md"), entries)).rejects.toThrow("not found")
|
||||
await CommandFiles.remove(file, entries)
|
||||
await expect(CommandFiles.remove(file, entries)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -12,7 +12,6 @@ import { AgentCommand } from "../../src/cli/cmd/agent"
|
||||
import { UpgradeCommand } from "../../src/cli/cmd/upgrade"
|
||||
import { UninstallCommand } from "../../src/cli/cmd/uninstall"
|
||||
import { ServeCommand } from "../../src/cli/cmd/serve"
|
||||
import { WebCommand } from "../../src/cli/cmd/web"
|
||||
import { ModelsCommand } from "../../src/cli/cmd/models"
|
||||
import { StatsCommand } from "../../src/cli/cmd/stats"
|
||||
import { ExportCommand } from "../../src/cli/cmd/export"
|
||||
@@ -64,7 +63,6 @@ const commands = [
|
||||
UpgradeCommand,
|
||||
UninstallCommand,
|
||||
ServeCommand,
|
||||
WebCommand,
|
||||
ModelsCommand,
|
||||
StatsCommand,
|
||||
ExportCommand,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mkdir, rm } from "fs/promises"
|
||||
import path from "path"
|
||||
import { KiloMemory } from "@kilocode/kilo-memory/effect"
|
||||
import { MemoryPaths } from "@kilocode/kilo-memory/effect/paths"
|
||||
import { array, check, object } from "../../server/httpapi-exercise/assertions"
|
||||
import { array, check, isRecord, object } from "../../server/httpapi-exercise/assertions"
|
||||
import { http, route } from "../../server/httpapi-exercise/dsl"
|
||||
import type { Scenario, ScenarioContext } from "../../server/httpapi-exercise/types"
|
||||
import { anacondaDesktopScenarios } from "../anaconda-desktop/httpapi-exercise-scenarios"
|
||||
@@ -37,6 +37,13 @@ const agent = async (dir: string) => {
|
||||
)
|
||||
}
|
||||
|
||||
const command = async (dir: string) => {
|
||||
await Bun.write(
|
||||
path.join(dir, ".kilo/command/httpapi-remove.md"),
|
||||
"---\ndescription: HTTP API command remove\nmodel: anthropic/claude-sonnet-4-6\nvariant: high\n---\nRun command.\n",
|
||||
)
|
||||
}
|
||||
|
||||
function memory(ctx: ScenarioContext) {
|
||||
const dir = directory(ctx)
|
||||
return MemoryPaths.root({ ctx: { directory: dir, worktree: dir } })
|
||||
@@ -544,6 +551,44 @@ export const kiloScenarios: Scenario[] = [
|
||||
array(body.mcps)
|
||||
array(body.vscode_extensions)
|
||||
}),
|
||||
http.protected
|
||||
.get("/kilocode/command/files", "kilocode.commandFiles")
|
||||
.inProject({ git: true, init: command })
|
||||
.json(200, (body, ctx) => {
|
||||
array(body)
|
||||
const item = body.find((item) => isRecord(item) && item.name === "httpapi-remove")
|
||||
object(item)
|
||||
check(item.description === "HTTP API command remove", "command file should include description")
|
||||
check(
|
||||
item.location === path.join(directory(ctx), ".kilo/command/httpapi-remove.md"),
|
||||
"command file should include location",
|
||||
)
|
||||
check(item.editable === true, "command file should be editable")
|
||||
check(item.builtin === false, "command file should not be builtin")
|
||||
check(item.model === "anthropic/claude-sonnet-4-6", "command file should include model metadata")
|
||||
check(item.variant === "high", "command file should include variant metadata")
|
||||
check(typeof item.content === "string" && item.content.includes("Run command."), "command file should include content")
|
||||
}),
|
||||
http.protected
|
||||
.post("/kilocode/command/remove", "kilocode.removeCommand")
|
||||
.inProject({ git: true, init: command })
|
||||
.mutating()
|
||||
.preserveDatabase()
|
||||
.at((ctx) => ({
|
||||
path: "/kilocode/command/remove",
|
||||
headers: ctx.headers(),
|
||||
body: { location: path.join(directory(ctx), ".kilo/command/httpapi-remove.md") },
|
||||
}))
|
||||
.jsonEffect(200, (body, ctx) =>
|
||||
Effect.gen(function* () {
|
||||
check(body === true, "command removal should return true")
|
||||
const location = path.join(directory(ctx), ".kilo/command/httpapi-remove.md")
|
||||
check(
|
||||
!(yield* Effect.promise(() => Bun.file(location).exists())),
|
||||
"removed command should not remain on disk",
|
||||
)
|
||||
}),
|
||||
),
|
||||
http.protected
|
||||
.post("/kilocode/skill/remove", "kilocode.removeSkill")
|
||||
.inProject({ git: true, init: skill })
|
||||
|
||||
@@ -144,7 +144,8 @@ describe("test runner cleanup", () => {
|
||||
const stderr = new Response(proc.stderr).text()
|
||||
|
||||
try {
|
||||
const code = await deadline(proc.exited, 15_000)
|
||||
const limit = process.platform === "win32" ? 30_000 : 15_000
|
||||
const code = await deadline(proc.exited, limit)
|
||||
const output = await Promise.all([stdout, stderr])
|
||||
expect(code, output[1] || output[0]).not.toBe(0)
|
||||
expect(output[0]).toContain("TIME")
|
||||
@@ -167,7 +168,7 @@ describe("test runner cleanup", () => {
|
||||
await proc.exited
|
||||
await fs.rm(file, { force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
}, 45_000)
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"bounds inherited output after the test process exits",
|
||||
|
||||
Reference in New Issue
Block a user