mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge branch 'main' into ionized-emmental
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# @kilocode/cli
|
||||
|
||||
## 7.2.50
|
||||
|
||||
## 7.2.49
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "7.2.49",
|
||||
"version": "7.2.50",
|
||||
"name": "@kilocode/cli",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -28,6 +28,7 @@ import { TodoWriteTool } from "../../tool/todo"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { importCloudSession, validateCloudFork } from "@/kilocode/cloud-session" // kilocode_change
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { KiloRunAuto } from "@/kilocode/cli/run-auto" // kilocode_change
|
||||
|
||||
type ToolProps<T> = {
|
||||
input: Tool.InferParameters<T>
|
||||
@@ -295,12 +296,7 @@ export const RunCommand = cmd({
|
||||
describe: "auto-approve all permissions (for autonomous/pipeline usage)",
|
||||
default: false,
|
||||
})
|
||||
// kilocode_change end
|
||||
.option("dangerously-skip-permissions", {
|
||||
type: "boolean",
|
||||
describe: "auto-approve permissions that are not explicitly denied (dangerous!)",
|
||||
default: false,
|
||||
})
|
||||
// kilocode_change end
|
||||
)
|
||||
},
|
||||
handler: async (args) => {
|
||||
@@ -477,6 +473,9 @@ export const RunCommand = cmd({
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
const part = event.properties.part
|
||||
// kilocode_change start - track Task child sessions for --auto permission replies
|
||||
if (args.auto) KiloRunAuto.track(auto, part)
|
||||
// kilocode_change end
|
||||
if (part.sessionID !== sessionID) continue
|
||||
|
||||
if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) {
|
||||
@@ -571,25 +570,27 @@ export const RunCommand = cmd({
|
||||
|
||||
if (event.type === "permission.asked") {
|
||||
const permission = event.properties
|
||||
if (permission.sessionID !== sessionID) continue
|
||||
|
||||
// kilocode_change start - In auto mode, approve root and tracked Task child permissions only
|
||||
if (args.auto) {
|
||||
// kilocode_change - In auto mode, automatically approve all permissions without prompting
|
||||
if (!KiloRunAuto.allowed(auto, permission.sessionID)) continue
|
||||
await sdk.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: "once",
|
||||
})
|
||||
} else {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL +
|
||||
`permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
|
||||
)
|
||||
await sdk.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: "reject",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (permission.sessionID !== sessionID) continue
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL +
|
||||
`permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
|
||||
)
|
||||
await sdk.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: "reject",
|
||||
})
|
||||
// kilocode_change end
|
||||
}
|
||||
// kilocode_change start - network retry handling
|
||||
if (event.type === "session.network.asked") {
|
||||
@@ -682,6 +683,7 @@ export const RunCommand = cmd({
|
||||
UI.error("Session not found")
|
||||
process.exit(1)
|
||||
}
|
||||
const auto = KiloRunAuto.create(sessionID) // kilocode_change
|
||||
await share(sdk, sessionID)
|
||||
|
||||
loop().catch((e) => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useDialog } from "../../ui/dialog"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { useTuiConfig } from "../../context/tui-config"
|
||||
import { ConfigProtection } from "@/kilocode/permission/config-paths" // kilocode_change
|
||||
import { normalizeUrls } from "@/kilocode/util/url" // kilocode_change
|
||||
|
||||
type PermissionStage = "permission" | "always" | "reject"
|
||||
|
||||
@@ -294,7 +295,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
||||
if (permission === "bash") {
|
||||
const title =
|
||||
typeof data.description === "string" && data.description ? data.description : "Shell command"
|
||||
const command = typeof data.command === "string" ? data.command : ""
|
||||
const command = normalizeUrls(typeof data.command === "string" ? data.command : "") // kilocode_change
|
||||
return {
|
||||
icon: "#",
|
||||
title,
|
||||
@@ -325,7 +326,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
||||
}
|
||||
|
||||
if (permission === "webfetch") {
|
||||
const url = typeof data.url === "string" ? data.url : ""
|
||||
const url = normalizeUrls(typeof data.url === "string" ? data.url : "") // kilocode_change
|
||||
return {
|
||||
icon: "%",
|
||||
title: `WebFetch ${url}`,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// kilocode_change - new file
|
||||
export namespace KiloRunAuto {
|
||||
export interface State {
|
||||
root: string
|
||||
sessions: Set<string>
|
||||
}
|
||||
|
||||
export interface Part {
|
||||
type?: string
|
||||
tool?: string
|
||||
sessionID?: string
|
||||
state?: unknown
|
||||
}
|
||||
|
||||
export function create(root: string): State {
|
||||
return {
|
||||
root,
|
||||
sessions: new Set([root]),
|
||||
}
|
||||
}
|
||||
|
||||
export function allowed(state: State, sessionID: string) {
|
||||
return state.sessions.has(sessionID)
|
||||
}
|
||||
|
||||
export function track(state: State, part: Part) {
|
||||
if (part.type !== "tool") return
|
||||
if (part.tool !== "task") return
|
||||
if (part.sessionID !== state.root) return
|
||||
const id = child(meta(part.state))
|
||||
if (!id) return
|
||||
state.sessions.add(id)
|
||||
}
|
||||
|
||||
function meta(state: unknown) {
|
||||
if (!state || typeof state !== "object") return
|
||||
return (state as Record<string, unknown>).metadata
|
||||
}
|
||||
|
||||
function child(meta: unknown) {
|
||||
if (!meta || typeof meta !== "object") return
|
||||
const id = (meta as Record<string, unknown>).sessionId
|
||||
if (typeof id !== "string") return
|
||||
if (!id) return
|
||||
return id
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,11 @@ export function fmtPrice(n: number): string {
|
||||
return `$${n.toFixed(2)}/1M`
|
||||
}
|
||||
|
||||
export function fmtCachedPrice(cost: Cost): string {
|
||||
export function fmtCachedPrice(cost: Cost): string | null {
|
||||
const read = cost.cache.read
|
||||
if (read > 0) return fmtPrice(read)
|
||||
if (cost.input === 0) return fmtPrice(0)
|
||||
return "N/A"
|
||||
return null
|
||||
}
|
||||
|
||||
export function avgPrice(cost: Cost): number {
|
||||
@@ -31,3 +31,9 @@ export function fmtContext(n: number): string {
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}K`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
export function fmtDate(s: string): string {
|
||||
const d = new Date(s)
|
||||
if (isNaN(d.getTime())) return s
|
||||
return d.toLocaleDateString(undefined, { year: "numeric", month: "short" })
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import type { Model } from "@kilocode/sdk/v2"
|
||||
import { avgPrice, fmtCachedPrice, fmtContext, fmtPrice } from "./model-info-panel-utils"
|
||||
import { Show } from "solid-js"
|
||||
import { avgPrice, fmtCachedPrice, fmtContext, fmtDate, fmtPrice } from "./model-info-panel-utils"
|
||||
|
||||
interface Props {
|
||||
model: Model
|
||||
@@ -12,6 +13,40 @@ interface Props {
|
||||
export function ModelInfoPanel(props: Props) {
|
||||
const { theme } = useTheme()
|
||||
const m = () => props.model
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
const maxHeight = createMemo(() => Math.floor(dimensions().height / 2) - 3)
|
||||
|
||||
const cost = () => m().cost
|
||||
const cached = () => (cost() ? fmtCachedPrice(cost()) : null)
|
||||
const avg = () => (cost() ? avgPrice(cost()) : undefined)
|
||||
const caps = () => m().capabilities
|
||||
const inputs = () => caps()?.input
|
||||
const outputs = () => caps()?.output
|
||||
const activeInputModalities = () => {
|
||||
if (!inputs()) return [] as string[]
|
||||
return Object.entries(inputs())
|
||||
.filter(([k, v]) => v && k !== "text")
|
||||
.map(([k]) => k.charAt(0).toUpperCase() + k.slice(1))
|
||||
}
|
||||
const activeOutputModalities = () => {
|
||||
if (!outputs()) return [] as string[]
|
||||
return Object.entries(outputs())
|
||||
.filter(([k, v]) => v && k !== "text")
|
||||
.map(([k]) => k.charAt(0).toUpperCase() + k.slice(1))
|
||||
}
|
||||
const inputLine = () => {
|
||||
const mods = activeInputModalities()
|
||||
return mods.length > 0 ? `In: ${mods.join(", ")}` : null
|
||||
}
|
||||
const outputLine = () => {
|
||||
const mods = activeOutputModalities()
|
||||
return mods.length > 0 ? `Out: ${mods.join(", ")}` : null
|
||||
}
|
||||
const desc = () => {
|
||||
const d = m().options?.description
|
||||
return typeof d === "string" && d.trim() ? d : null
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
@@ -24,42 +59,94 @@ export function ModelInfoPanel(props: Props) {
|
||||
gap={1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<box>
|
||||
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
||||
{m().name ?? m().id ?? "Model"}
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{props.provider ?? m().providerID ?? ""}</text>
|
||||
</box>
|
||||
<box>
|
||||
<scrollbox
|
||||
maxHeight={maxHeight()}
|
||||
paddingRight={1}
|
||||
>
|
||||
<box>
|
||||
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
||||
{m().name ?? m().id ?? "Model"}
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{props.provider ?? m().providerID ?? ""}</text>
|
||||
</box>
|
||||
<Show when={m().isFree}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<box>
|
||||
<text fg={theme.text}>Free</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!m().isFree}>
|
||||
<Show when={m().family}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.textMuted}>Input</text>
|
||||
<text fg={theme.text}>{m() ? fmtPrice(m().cost.input) : "—"}</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.textMuted}>Output</text>
|
||||
<text fg={theme.text}>{m() ? fmtPrice(m().cost.output) : "—"}</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.textMuted}>Cache Read</text>
|
||||
<text fg={theme.text}>{m() ? fmtCachedPrice(m().cost) : "—"}</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between"></box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.textMuted}>Context Size</text>
|
||||
<text fg={theme.text}>{m() ? fmtContext(m().limit.context) : "—"}</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.textMuted}>Average Cost</text>
|
||||
<text fg={theme.text}>{m() ? fmtPrice(avgPrice(m().cost)) : "—"}</text>
|
||||
<text fg={theme.textMuted}>Family</text>
|
||||
<text fg={theme.text}>{m().family!.charAt(0).toUpperCase() + m().family!.slice(1)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={m().release_date}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.textMuted}>Released</text>
|
||||
<text fg={theme.text}>{fmtDate(m().release_date)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!m().isFree}>
|
||||
<box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.textMuted}>Input</text>
|
||||
<text fg={theme.text}>{m() ? fmtPrice(m().cost.input) : "—"}</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.textMuted}>Output</text>
|
||||
<text fg={theme.text}>{m() ? fmtPrice(m().cost.output) : "—"}</text>
|
||||
</box>
|
||||
<Show when={cached()}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.textMuted}>Cached</text>
|
||||
<text fg={theme.text}>{cached()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={avg() !== undefined}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.textMuted}>Avg Cost</text>
|
||||
<text fg={theme.text}>{m() ? fmtPrice(avg()!) : "—"}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.textMuted}>Context</text>
|
||||
<text fg={theme.text}>{m() ? fmtContext(m().limit.context) : "—"}</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={caps()?.reasoning || inputLine() || outputLine()}>
|
||||
<box flexDirection="column">
|
||||
<Show when={caps()?.reasoning}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.textMuted}>Reasoning</text>
|
||||
<text fg={theme.text}>Yes</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={inputLine()}>
|
||||
{(line) => (
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.textMuted}>Caps</text>
|
||||
<text fg={theme.text}>{line().replace(/^In:\s*/, "")}</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={outputLine()}>
|
||||
{(line) => (
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.textMuted}>Out</text>
|
||||
<text fg={theme.text}>{line().replace(/^Out:\s*/, "")}</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={desc()}>
|
||||
<text fg={theme.textMuted}>{" "}</text>
|
||||
<text fg={theme.textMuted} width={23}>
|
||||
{desc()}
|
||||
</text>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
- Find files most relevant to the search query using semantic search.
|
||||
- Find code snippets most relevant to the search query using semantic search.
|
||||
- Returns matching content with file paths, line ranges, and relevance scores.
|
||||
- Searches based on meaning rather than exact text matches.
|
||||
- By default searches entire workspace, with capability to filter by path.
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ export namespace KiloTask {
|
||||
if (info.mode === "primary") throw new Error(`Agent "${name}" is a primary agent and cannot be used as a subagent`)
|
||||
}
|
||||
|
||||
/** Kilo keeps delegation one level deep to avoid recursive subagent chains. */
|
||||
export function nestedTask(): false {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Build inherited permission rules from the calling agent.
|
||||
* Merges the static agent definition with the session's accumulated permissions
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Normalize any http/https URLs in a string so that IDN/Unicode hostnames are
|
||||
* converted to their punycode ASCII form, preventing homograph attacks in
|
||||
* permission dialogs where visually identical Unicode characters (e.g. Cyrillic
|
||||
* 'а' U+0430) could impersonate trusted domains (e.g. 'apitest.com').
|
||||
*
|
||||
* Example: "curl https://аpitest.com/status" (Cyrillic а)
|
||||
* → "curl https://xn--pitest-2nf.com/status"
|
||||
*
|
||||
* Trailing sentence punctuation (. , ! ? ; :) that \S+ would otherwise consume
|
||||
* into the URL match is stripped before parsing and left in place afterward, so
|
||||
* plain-text prose like "see https://example.com." is returned unchanged.
|
||||
*
|
||||
* Only the hostname is replaced, not the full href, to avoid side-effects such
|
||||
* as new URL() appending a trailing slash to bare origins.
|
||||
*/
|
||||
export function normalizeUrls(text: string) {
|
||||
return text.replace(/https?:\/\/\S+/g, (match) => {
|
||||
// Strip trailing sentence punctuation that \S+ greedily consumes but that
|
||||
// is almost certainly not part of the URL (e.g. "visit https://x.com.").
|
||||
const stripped = match.replace(/[.,!?;:)"'\]>]+$/, "")
|
||||
const tail = match.slice(stripped.length)
|
||||
try {
|
||||
const parsed = new URL(stripped)
|
||||
// Extract the raw hostname from the stripped string so we can replace
|
||||
// only that part — using href would add a trailing slash to bare origins.
|
||||
const afterScheme = stripped.indexOf("//") + 2
|
||||
const slashPos = stripped.indexOf("/", afterScheme)
|
||||
const rawHost = slashPos === -1 ? stripped.slice(afterScheme) : stripped.slice(afterScheme, slashPos)
|
||||
const colon = rawHost.indexOf(":")
|
||||
const rawHostname = colon === -1 ? rawHost : rawHost.slice(0, colon)
|
||||
if (rawHostname === parsed.hostname) return match // plain ASCII — nothing to change
|
||||
return stripped.replace(rawHostname, parsed.hostname) + tail
|
||||
} catch {
|
||||
return match
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -92,6 +92,10 @@ type CompletedCompaction = {
|
||||
summary: string | undefined
|
||||
}
|
||||
|
||||
// kilocode_change start - allow safe pruning at cache-invalidating boundaries
|
||||
export type PruneReason = "normal" | "post-compaction" | "payload-limit"
|
||||
// kilocode_change end
|
||||
|
||||
function summaryText(message: MessageV2.WithParts) {
|
||||
const text = message.parts
|
||||
.filter((part): part is MessageV2.TextPart => part.type === "text")
|
||||
@@ -188,7 +192,7 @@ export interface Interface {
|
||||
tokens: MessageV2.Assistant["tokens"]
|
||||
model: Provider.Model
|
||||
}) => Effect.Effect<boolean>
|
||||
readonly prune: (input: { sessionID: SessionID }) => Effect.Effect<void>
|
||||
readonly prune: (input: { sessionID: SessionID; reason?: PruneReason }) => Effect.Effect<void> // kilocode_change
|
||||
readonly process: (input: {
|
||||
parentID: MessageID
|
||||
messages: MessageV2.WithParts[]
|
||||
@@ -296,10 +300,13 @@ export const layer: Layer.Layer<
|
||||
|
||||
// goes backwards through parts until there are PRUNE_PROTECT tokens worth of tool
|
||||
// calls, then erases output of older tool calls to free context space
|
||||
const prune = Effect.fn("SessionCompaction.prune")(function* (input: { sessionID: SessionID }) {
|
||||
// kilocode_change start - preserve normal opt-in pruning, but allow payload/compaction cleanup by default
|
||||
const prune = Effect.fn("SessionCompaction.prune")(function* (input: { sessionID: SessionID; reason?: PruneReason }) {
|
||||
const cfg = yield* config.get()
|
||||
if (!cfg.compaction?.prune) return
|
||||
log.info("pruning")
|
||||
const reason = input.reason ?? "normal"
|
||||
if (cfg.compaction?.prune === false) return
|
||||
if (reason === "normal" && cfg.compaction?.prune !== true) return
|
||||
log.info("pruning", { reason })
|
||||
|
||||
const msgs = yield* session
|
||||
.messages({ sessionID: input.sessionID })
|
||||
@@ -338,9 +345,10 @@ export const layer: Layer.Layer<
|
||||
yield* session.updatePart(part)
|
||||
}
|
||||
}
|
||||
log.info("pruned", { count: toPrune.length })
|
||||
log.info("pruned", { reason, count: toPrune.length })
|
||||
}
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
const processCompaction = Effect.fn("SessionCompaction.process")(function* (input: {
|
||||
parentID: MessageID
|
||||
@@ -556,8 +564,13 @@ export const layer: Layer.Layer<
|
||||
}
|
||||
}
|
||||
|
||||
// kilocode_change start - compaction already invalidates cache, so collapse stale tool outputs too
|
||||
if (processor.message.error) return "stop"
|
||||
if (result === "continue") yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
|
||||
if (result === "continue") {
|
||||
yield* prune({ sessionID: input.sessionID, reason: "post-compaction" })
|
||||
yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
|
||||
}
|
||||
// kilocode_change end
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -612,11 +625,11 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
|
||||
export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) { // kilocode_change
|
||||
return runPromise((svc) => svc.isOverflow(input))
|
||||
}
|
||||
|
||||
export async function prune(input: { sessionID: SessionID }) {
|
||||
export async function prune(input: { sessionID: SessionID; reason?: PruneReason }) { // kilocode_change
|
||||
return runPromise((svc) => svc.prune(input))
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,10 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc
|
||||
// kilocode_change
|
||||
export const shouldAskPlanFollowup = KiloSessionPrompt.shouldAskPlanFollowup
|
||||
|
||||
// kilocode_change start - persistent tool-output pruning when payload is already large
|
||||
const REQUEST_PRUNE_BYTES = 1_250_000
|
||||
// kilocode_change end
|
||||
|
||||
const log = Log.create({ service: "session.prompt" })
|
||||
const elog = EffectLogger.create({ service: "session.prompt" })
|
||||
|
||||
@@ -1333,7 +1337,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
// kilocode_change end
|
||||
},
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
const lastAssistant = Effect.fnUntraced(function* (sessionID: SessionID) {
|
||||
// kilocode_change start - retry when cancel races before shellImpl writes messages
|
||||
@@ -1587,12 +1590,27 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
msgs = KiloSessionPrompt.maybeStripHistoricalMedia(msgs)
|
||||
// kilocode_change end
|
||||
|
||||
const [skills, env, instructions, modelMsgs] = yield* Effect.all([
|
||||
// kilocode_change start - persistently prune stale tool outputs when payload is already large
|
||||
const [skills, env, instructions] = yield* Effect.all([
|
||||
sys.skills(agent),
|
||||
sys.environment(model, lastUser.editorContext), // kilocode_change
|
||||
instruction.system().pipe(Effect.orDie),
|
||||
MessageV2.toModelMessagesEffect(msgs, model),
|
||||
])
|
||||
let modelMsgs = yield* MessageV2.toModelMessagesEffect(msgs, model)
|
||||
const size = Buffer.byteLength(JSON.stringify(modelMsgs))
|
||||
if (size > REQUEST_PRUNE_BYTES) {
|
||||
yield* compaction.prune({ sessionID, reason: "payload-limit" })
|
||||
msgs = yield* MessageV2.filterCompactedEffect(sessionID)
|
||||
msgs = KiloSessionPromptQueue.scope(sessionID, msgs)
|
||||
msgs = KiloSessionPrompt.trimBeforeLastSummary(msgs)
|
||||
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
||||
KiloSessionPrompt.injectEditorContext({ msgs, lastUser, sessionID, cache: envCache })
|
||||
msgs = KiloSessionPrompt.maybeStripHistoricalMedia(msgs)
|
||||
modelMsgs = yield* MessageV2.toModelMessagesEffect(msgs, model)
|
||||
const nextSize = Buffer.byteLength(JSON.stringify(modelMsgs))
|
||||
if (nextSize > REQUEST_PRUNE_BYTES) log.warn("payload still large after pruning", { size: nextSize })
|
||||
}
|
||||
// kilocode_change end
|
||||
const system = [...env, ...instructions, ...(skills ? [skills] : [])]
|
||||
const format = lastUser.format ?? { type: "text" as const }
|
||||
if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) // kilocode_change
|
||||
@@ -1698,7 +1716,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
continue
|
||||
}
|
||||
|
||||
yield* compaction.prune({ sessionID }).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||
yield* compaction.prune({ sessionID, reason: "normal" }).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||
return yield* lastAssistant(sessionID)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Shell } from "@/shell/shell"
|
||||
import { BashArity } from "@/permission/arity"
|
||||
import * as Truncate from "./truncate"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { normalizeUrls } from "@/kilocode/util/url" // kilocode_change
|
||||
import { Effect, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
@@ -238,6 +239,7 @@ function preview(text: string) {
|
||||
return "...\n\n" + text.slice(-MAX_METADATA_LENGTH)
|
||||
}
|
||||
|
||||
|
||||
function tail(text: string, maxLines: number, maxBytes: number) {
|
||||
const lines = text.split("\n")
|
||||
if (lines.length <= maxLines && Buffer.byteLength(text, "utf-8") <= maxBytes) {
|
||||
@@ -296,7 +298,7 @@ const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan,
|
||||
permission: "bash",
|
||||
patterns: Array.from(scan.patterns),
|
||||
always: Array.from(scan.always),
|
||||
metadata: { command }, // kilocode_change
|
||||
metadata: { command: normalizeUrls(command) }, // kilocode_change
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ export const TaskTool = Tool.define(
|
||||
KiloTask.validate(next, params.subagent_type)
|
||||
// kilocode_change end
|
||||
|
||||
const canTask = next.permission.some((rule) => rule.permission === id)
|
||||
const canTask = KiloTask.nestedTask() // kilocode_change - Kilo disallows subagents spawning subagents
|
||||
const canTodo = next.permission.some((rule) => rule.permission === "todowrite")
|
||||
|
||||
const parent = yield* sessions.get(ctx.sessionID)
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as Tool from "./tool"
|
||||
import TurndownService from "turndown"
|
||||
import DESCRIPTION from "./webfetch.txt"
|
||||
import { isImageAttachment } from "@/util/media"
|
||||
import { normalizeUrls } from "@/kilocode/util/url" // kilocode_change
|
||||
|
||||
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
|
||||
@@ -34,12 +35,14 @@ export const WebFetchTool = Tool.define(
|
||||
throw new Error("URL must start with http:// or https://")
|
||||
}
|
||||
|
||||
const url = normalizeUrls(params.url) // kilocode_change
|
||||
|
||||
yield* ctx.ask({
|
||||
permission: "webfetch",
|
||||
patterns: [params.url],
|
||||
patterns: [url], // kilocode_change
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
url: params.url,
|
||||
url, // kilocode_change
|
||||
format: params.format,
|
||||
timeout: params.timeout,
|
||||
},
|
||||
@@ -71,7 +74,7 @@ export const WebFetchTool = Tool.define(
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
}
|
||||
|
||||
const request = HttpClientRequest.get(params.url).pipe(HttpClientRequest.setHeaders(headers))
|
||||
const request = HttpClientRequest.get(url).pipe(HttpClientRequest.setHeaders(headers)) // kilocode_change
|
||||
|
||||
// Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch)
|
||||
const response = yield* httpOk.execute(request).pipe(
|
||||
@@ -82,7 +85,7 @@ export const WebFetchTool = Tool.define(
|
||||
err.reason.response.headers["cf-mitigated"] === "challenge",
|
||||
() =>
|
||||
httpOk.execute(
|
||||
HttpClientRequest.get(params.url).pipe(
|
||||
HttpClientRequest.get(url).pipe( // kilocode_change
|
||||
HttpClientRequest.setHeaders({ ...headers, "User-Agent": "kilo" }), // kilocode_change
|
||||
),
|
||||
),
|
||||
@@ -103,7 +106,7 @@ export const WebFetchTool = Tool.define(
|
||||
|
||||
const contentType = response.headers["content-type"] || ""
|
||||
const mime = contentType.split(";")[0]?.trim().toLowerCase() || ""
|
||||
const title = `${params.url} (${contentType})`
|
||||
const title = `${url} (${contentType})` // kilocode_change
|
||||
|
||||
if (isImageAttachment(mime)) {
|
||||
const base64Content = Buffer.from(arrayBuffer).toString("base64")
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// kilocode_change - new file
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { KiloRunAuto } from "../../src/kilocode/cli/run-auto"
|
||||
|
||||
describe("KiloRunAuto", () => {
|
||||
test("tracks task child sessions without allowing unrelated sessions", () => {
|
||||
const state = KiloRunAuto.create("ses_root")
|
||||
|
||||
expect(KiloRunAuto.allowed(state, "ses_root")).toBe(true)
|
||||
expect(KiloRunAuto.allowed(state, "ses_child")).toBe(false)
|
||||
|
||||
KiloRunAuto.track(state, {
|
||||
type: "tool",
|
||||
tool: "task",
|
||||
sessionID: "ses_root",
|
||||
state: {
|
||||
metadata: {
|
||||
sessionId: "ses_child",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(KiloRunAuto.allowed(state, "ses_child")).toBe(true)
|
||||
expect(KiloRunAuto.allowed(state, "ses_other")).toBe(false)
|
||||
})
|
||||
|
||||
test("ignores malformed or non-root task metadata", () => {
|
||||
const state = KiloRunAuto.create("ses_root")
|
||||
|
||||
KiloRunAuto.track(state, {
|
||||
type: "tool",
|
||||
tool: "task",
|
||||
sessionID: "ses_root",
|
||||
state: {
|
||||
metadata: {
|
||||
sessionId: "",
|
||||
},
|
||||
},
|
||||
})
|
||||
KiloRunAuto.track(state, {
|
||||
type: "tool",
|
||||
tool: "task",
|
||||
sessionID: "ses_other",
|
||||
state: {
|
||||
metadata: {
|
||||
sessionId: "ses_wrong",
|
||||
},
|
||||
},
|
||||
})
|
||||
KiloRunAuto.track(state, {
|
||||
type: "text",
|
||||
sessionID: "ses_root",
|
||||
state: {},
|
||||
})
|
||||
|
||||
expect(KiloRunAuto.allowed(state, "ses_wrong")).toBe(false)
|
||||
expect(KiloRunAuto.allowed(state, "")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -22,8 +22,8 @@ describe("model info panel price formatting", () => {
|
||||
expect(fmtCachedPrice({ input: 0, output: 0, cache: { read: 0, write: 0 } })).toBe("Free")
|
||||
})
|
||||
|
||||
test("fmtCachedPrice returns N/A without cache read", () => {
|
||||
expect(fmtCachedPrice({ input: 3, output: 15, cache: { read: 0, write: 0 } })).toBe("N/A")
|
||||
test("fmtCachedPrice returns null without cache read", () => {
|
||||
expect(fmtCachedPrice({ input: 3, output: 15, cache: { read: 0, write: 0 } })).toBeNull()
|
||||
})
|
||||
|
||||
test("avgPrice uses cache weighted formula when cache read exists", () => {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// kilocode_change - new file
|
||||
import { afterEach, describe, expect, mock, test } from "bun:test"
|
||||
|
||||
type Event = {
|
||||
type: string
|
||||
properties: Record<string, unknown>
|
||||
}
|
||||
|
||||
function feed<T>() {
|
||||
const list: T[] = []
|
||||
const wait: Array<() => void> = []
|
||||
const state = { done: false }
|
||||
|
||||
return {
|
||||
push(item: T) {
|
||||
list.push(item)
|
||||
while (wait.length) wait.shift()?.()
|
||||
},
|
||||
end() {
|
||||
state.done = true
|
||||
while (wait.length) wait.shift()?.()
|
||||
},
|
||||
async *stream() {
|
||||
while (!state.done || list.length) {
|
||||
if (list.length) {
|
||||
yield list.shift() as T
|
||||
continue
|
||||
}
|
||||
await new Promise<void>((resolve) => wait.push(resolve))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function task(child: string): Event {
|
||||
return {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "prt_task",
|
||||
type: "tool",
|
||||
tool: "task",
|
||||
sessionID: "ses_root",
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
description: "inspect bug",
|
||||
prompt: "check child permissions",
|
||||
subagent_type: "general",
|
||||
},
|
||||
metadata: {
|
||||
sessionId: child,
|
||||
},
|
||||
time: { start: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function permission(id: string, sessionID: string): Event {
|
||||
return {
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id,
|
||||
sessionID,
|
||||
permission: "bash",
|
||||
patterns: ["npm test"],
|
||||
metadata: { command: "npm test" },
|
||||
always: ["npm *"],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function idle(): Event {
|
||||
return {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID: "ses_root",
|
||||
status: { type: "idle" },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function args() {
|
||||
return {
|
||||
_: [],
|
||||
$0: "kilo",
|
||||
message: ["hi"],
|
||||
command: undefined,
|
||||
continue: false,
|
||||
session: "ses_root",
|
||||
fork: false,
|
||||
"cloud-fork": false,
|
||||
cloudFork: false,
|
||||
share: false,
|
||||
model: undefined,
|
||||
agent: undefined,
|
||||
format: "json",
|
||||
file: undefined,
|
||||
title: undefined,
|
||||
attach: "http://127.0.0.1:4096",
|
||||
password: undefined,
|
||||
dir: undefined,
|
||||
port: undefined,
|
||||
variant: undefined,
|
||||
thinking: false,
|
||||
auto: true,
|
||||
"dangerously-skip-permissions": false,
|
||||
dangerouslySkipPermissions: false,
|
||||
"--": [],
|
||||
}
|
||||
}
|
||||
|
||||
const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY")
|
||||
|
||||
afterEach(() => {
|
||||
if (tty) {
|
||||
Object.defineProperty(process.stdin, "isTTY", tty)
|
||||
return
|
||||
}
|
||||
delete (process.stdin as { isTTY?: boolean }).isTTY
|
||||
})
|
||||
|
||||
async function run(sdk: Record<string, unknown>) {
|
||||
mock.module("@kilocode/sdk/v2", () => ({
|
||||
createKiloClient: () => sdk,
|
||||
}))
|
||||
|
||||
Object.defineProperty(process.stdin, "isTTY", {
|
||||
configurable: true,
|
||||
value: true,
|
||||
})
|
||||
|
||||
const key = JSON.stringify({ time: Date.now(), rand: Math.random() })
|
||||
const { RunCommand } = await import(`../../src/cli/cmd/run?${key}`)
|
||||
return RunCommand.handler(args() as never)
|
||||
}
|
||||
|
||||
describe("cli run auto permissions", () => {
|
||||
test("auto approves tracked subagent permissions and ignores unrelated sessions", async () => {
|
||||
const q = feed<Event>()
|
||||
const calls: Array<{ requestID: string; reply: string }> = []
|
||||
const done = Promise.withResolvers<void>()
|
||||
|
||||
const sdk = {
|
||||
config: {
|
||||
get: async () => ({ data: { share: "manual" } }),
|
||||
},
|
||||
event: {
|
||||
subscribe: async () => ({ stream: q.stream() }),
|
||||
},
|
||||
permission: {
|
||||
reply: async (input: { requestID: string; reply: string }) => {
|
||||
calls.push(input)
|
||||
if (input.requestID === "perm_child") done.resolve()
|
||||
return { data: true }
|
||||
},
|
||||
},
|
||||
session: {
|
||||
prompt: async () => {
|
||||
q.push(task("ses_child"))
|
||||
q.push(permission("perm_other", "ses_other"))
|
||||
q.push(permission("perm_child", "ses_child"))
|
||||
q.push(idle())
|
||||
await Promise.race([done.promise, new Promise((resolve) => setTimeout(resolve, 25))])
|
||||
q.end()
|
||||
return { data: undefined }
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
await run(sdk)
|
||||
|
||||
expect(calls).toEqual([{ requestID: "perm_child", reply: "once" }])
|
||||
})
|
||||
})
|
||||
@@ -34,6 +34,14 @@ const baseCtx = {
|
||||
} satisfies Tool.Context
|
||||
|
||||
describe("tool.semantic_search", () => {
|
||||
test("describes code snippet results", async () => {
|
||||
const tool = await initTool()
|
||||
|
||||
expect(tool.description).toContain("Find code snippets most relevant")
|
||||
expect(tool.description).toContain("Returns matching content with file paths, line ranges, and relevance scores")
|
||||
expect(tool.description).not.toContain("Find files most relevant")
|
||||
})
|
||||
|
||||
test("throws when query is empty", async () => {
|
||||
const tool = await initTool()
|
||||
expect(rt.runPromise(tool.execute({ query: "" }, baseCtx))).rejects.toThrow("query is required")
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Config } from "../../src/config/config"
|
||||
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Session } from "../../src/session/session"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import type { SessionPrompt } from "../../src/session/prompt"
|
||||
import { MessageID, PartID } from "../../src/session/schema"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { ToolRegistry } from "../../src/tool/registry"
|
||||
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const ref = {
|
||||
providerID: ProviderID.make("test"),
|
||||
modelID: ModelID.make("test-model"),
|
||||
}
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
Agent.defaultLayer,
|
||||
Config.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
Session.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
ToolRegistry.defaultLayer,
|
||||
),
|
||||
)
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
const seed = Effect.fn("NestedTaskToolTest.seed")(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const chat = yield* sessions.create({ title: "Parent" })
|
||||
const user = yield* sessions.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: chat.id,
|
||||
agent: "build",
|
||||
model: ref,
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const assistant: MessageV2.Assistant = {
|
||||
id: MessageID.ascending(),
|
||||
role: "assistant",
|
||||
parentID: user.id,
|
||||
sessionID: chat.id,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
cost: 0,
|
||||
path: { cwd: "/tmp", root: "/tmp" },
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
modelID: ref.modelID,
|
||||
providerID: ref.providerID,
|
||||
time: { created: Date.now() },
|
||||
}
|
||||
yield* sessions.updateMessage(assistant)
|
||||
return { chat, assistant }
|
||||
})
|
||||
|
||||
function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void }): TaskPromptOps {
|
||||
return {
|
||||
cancel() {},
|
||||
resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
|
||||
prompt: (input) =>
|
||||
Effect.sync(() => {
|
||||
opts?.onPrompt?.(input)
|
||||
const id = MessageID.ascending()
|
||||
return {
|
||||
info: {
|
||||
id,
|
||||
role: "assistant",
|
||||
parentID: input.messageID ?? MessageID.ascending(),
|
||||
sessionID: input.sessionID,
|
||||
mode: input.agent ?? "general",
|
||||
agent: input.agent ?? "general",
|
||||
cost: 0,
|
||||
path: { cwd: "/tmp", root: "/tmp" },
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
modelID: ref.modelID,
|
||||
providerID: ref.providerID,
|
||||
time: { created: Date.now() },
|
||||
finish: "stop",
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: PartID.ascending(),
|
||||
messageID: id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
text: "done",
|
||||
},
|
||||
],
|
||||
} satisfies MessageV2.WithParts
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe("Kilo task nesting", () => {
|
||||
it.live("allows primary agents to delegate one level to a subagent", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const { chat, assistant } = yield* seed()
|
||||
const tool = yield* TaskTool
|
||||
const def = yield* tool.init()
|
||||
let seen: SessionPrompt.PromptInput | undefined
|
||||
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
|
||||
|
||||
const result = yield* def.execute(
|
||||
{
|
||||
description: "inspect bug",
|
||||
prompt: "look into the cache key path",
|
||||
subagent_type: "explore",
|
||||
},
|
||||
{
|
||||
sessionID: chat.id,
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
extra: { promptOps },
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask: () => Effect.void,
|
||||
},
|
||||
)
|
||||
|
||||
const kids = yield* sessions.children(chat.id)
|
||||
expect(kids).toHaveLength(1)
|
||||
expect(kids[0]?.id).toBe(result.metadata.sessionId)
|
||||
expect(kids[0]?.parentID).toBe(chat.id)
|
||||
expect(seen?.sessionID).toBe(result.metadata.sessionId)
|
||||
expect(seen?.agent).toBe("explore")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("disables nested task tool even when global task permission allows it", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const { chat, assistant } = yield* seed()
|
||||
const tool = yield* TaskTool
|
||||
const def = yield* tool.init()
|
||||
let seen: SessionPrompt.PromptInput | undefined
|
||||
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
|
||||
|
||||
const result = yield* def.execute(
|
||||
{
|
||||
description: "inspect bug",
|
||||
prompt: "look into the cache key path",
|
||||
subagent_type: "explore",
|
||||
},
|
||||
{
|
||||
sessionID: chat.id,
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
extra: { promptOps },
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask: () => Effect.void,
|
||||
},
|
||||
)
|
||||
|
||||
const child = yield* sessions.get(result.metadata.sessionId)
|
||||
expect(seen?.tools?.task).toBe(false)
|
||||
expect(child.permission).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
permission: "task",
|
||||
pattern: "*",
|
||||
action: "deny",
|
||||
},
|
||||
]),
|
||||
)
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
permission: {
|
||||
task: "allow",
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
// kilocode_change - new file
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { normalizeUrls } from "../../../src/kilocode/util/url"
|
||||
|
||||
describe("normalizeUrls", () => {
|
||||
describe("homograph / IDN conversion", () => {
|
||||
test("converts Cyrillic look-alike in hostname to punycode", () => {
|
||||
// Cyrillic 'а' (U+0430) is visually identical to Latin 'a'
|
||||
const input = "https://\u0430pitest.com/status"
|
||||
const result = normalizeUrls(input)
|
||||
expect(result).toBe("https://xn--pitest-2nf.com/status")
|
||||
expect(result).not.toContain("\u0430")
|
||||
})
|
||||
|
||||
test("converts mixed-script hostname to punycode", () => {
|
||||
// Mix of Latin and Cyrillic in the same label
|
||||
const input = "https://\u0430pitest.com/path"
|
||||
expect(normalizeUrls(input)).not.toContain("\u0430")
|
||||
})
|
||||
|
||||
test("converts fully unicode TLD to punycode", () => {
|
||||
const input = "https://example.\u4e2d\u56fd"
|
||||
const result = normalizeUrls(input)
|
||||
expect(result).toMatch(/^https:\/\/example\.xn--/)
|
||||
})
|
||||
|
||||
test("handles http scheme as well as https", () => {
|
||||
const input = "http://\u0430pitest.com/path"
|
||||
const result = normalizeUrls(input)
|
||||
expect(result).not.toContain("\u0430")
|
||||
expect(result).toMatch(/^http:\/\/xn--/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("plain ASCII URLs are unchanged", () => {
|
||||
test("leaves a URL with a path untouched", () => {
|
||||
const url = "https://apitest.com/status"
|
||||
expect(normalizeUrls(url)).toBe(url)
|
||||
})
|
||||
|
||||
test("leaves a URL with path and query string untouched", () => {
|
||||
const url = "http://example.com/foo?bar=1&baz=2"
|
||||
expect(normalizeUrls(url)).toBe(url)
|
||||
})
|
||||
|
||||
test("leaves a localhost URL with port untouched", () => {
|
||||
const url = "http://localhost:3000/api"
|
||||
expect(normalizeUrls(url)).toBe(url)
|
||||
})
|
||||
|
||||
test("leaves a bare origin untouched (no trailing slash added)", () => {
|
||||
// Regression: new URL("https://example.com").href === "https://example.com/"
|
||||
// The old implementation using href would mutate bare origins by adding "/".
|
||||
const url = "https://example.com"
|
||||
expect(normalizeUrls(url)).toBe(url)
|
||||
})
|
||||
})
|
||||
|
||||
describe("trailing sentence punctuation is not consumed into the URL", () => {
|
||||
test("period at end of sentence is not consumed (was: adds trailing slash)", () => {
|
||||
// "see https://example.com." — the period ends the sentence, not the URL.
|
||||
// Old behaviour (bug): returned "see https://example.com./"
|
||||
expect(normalizeUrls("see https://example.com.")).toBe("see https://example.com.")
|
||||
})
|
||||
|
||||
test("exclamation mark at end of sentence is not consumed", () => {
|
||||
expect(normalizeUrls("visit https://example.com!")).toBe("visit https://example.com!")
|
||||
})
|
||||
|
||||
test("comma after URL in a list is not consumed", () => {
|
||||
expect(normalizeUrls("check https://example.com, then continue")).toBe(
|
||||
"check https://example.com, then continue",
|
||||
)
|
||||
})
|
||||
|
||||
test("closing parenthesis after URL is not consumed", () => {
|
||||
expect(normalizeUrls("(see https://example.com)")).toBe("(see https://example.com)")
|
||||
})
|
||||
|
||||
test("trailing punctuation after an IDN URL is stripped correctly and punycode applied", () => {
|
||||
// Trailing period on a homograph URL: period is sentence punctuation, not part of the URL.
|
||||
const input = "see https://\u0430pitest.com."
|
||||
const result = normalizeUrls(input)
|
||||
expect(result).toBe("see https://xn--pitest-2nf.com.")
|
||||
expect(result).not.toContain("\u0430")
|
||||
})
|
||||
})
|
||||
|
||||
describe("URL embedded in a bash command string", () => {
|
||||
test("normalizes the URL portion of a curl command", () => {
|
||||
const input = "curl https://\u0430pitest.com/status"
|
||||
const result = normalizeUrls(input)
|
||||
expect(result).toMatch(/^curl https:\/\/xn--/)
|
||||
expect(result).not.toContain("\u0430")
|
||||
})
|
||||
|
||||
test("preserves flags and pipe around the URL", () => {
|
||||
const input = "curl -sSf https://\u0430pitest.com/status | bash"
|
||||
const result = normalizeUrls(input)
|
||||
expect(result).toMatch(/^curl -sSf /)
|
||||
expect(result).toContain("| bash")
|
||||
})
|
||||
|
||||
test("normalizes multiple URLs in a single command", () => {
|
||||
const input = "curl https://\u0430pitest.com/a && curl https://\u0430pitest.com/b"
|
||||
const result = normalizeUrls(input)
|
||||
expect(result.match(/xn--/g)?.length).toBe(2)
|
||||
expect(result).not.toContain("\u0430")
|
||||
})
|
||||
|
||||
test("leaves a plain-ASCII command entirely unchanged", () => {
|
||||
const input = "curl -sSf https://kilo.ai/update.sh | bash"
|
||||
expect(normalizeUrls(input)).toBe(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("returns empty string unchanged", () => {
|
||||
expect(normalizeUrls("")).toBe("")
|
||||
})
|
||||
|
||||
test("returns text with no URLs unchanged", () => {
|
||||
const text = "just some plain text without links"
|
||||
expect(normalizeUrls(text)).toBe(text)
|
||||
})
|
||||
|
||||
test("does not alter non-http/https schemes", () => {
|
||||
const text = "ftp://example.com and file:///tmp/foo"
|
||||
expect(normalizeUrls(text)).toBe(text)
|
||||
})
|
||||
|
||||
test("preserves path, query string, and fragment after IDN conversion", () => {
|
||||
const input = "https://\u0430pitest.com/path?q=1#anchor"
|
||||
const result = normalizeUrls(input)
|
||||
expect(result).toMatch(/xn--/)
|
||||
expect(result).toContain("/path?q=1#anchor")
|
||||
})
|
||||
|
||||
test("preserves a URL that fails to parse verbatim", () => {
|
||||
const malformed = "https://[unclosed"
|
||||
expect(normalizeUrls(malformed)).toBe(malformed)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -383,6 +383,7 @@ describe("tool.task", () => {
|
||||
// kilocode_change end
|
||||
expect(seen?.tools).toEqual({
|
||||
todowrite: false,
|
||||
task: false, // kilocode_change - Kilo disallows nested subagents
|
||||
bash: false,
|
||||
read: false,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user