Add folder mentions (#9023)

* feat(vscode): add folder mentions

* fix(vscode): harden folder mentions

* refactor(vscode): tighten folder mention plumbing

* fix(cli): annotate kilo-specific read.ts exports
This commit is contained in:
Marius
2026-04-17 11:59:31 +02:00
committed by GitHub
parent 952b3096dc
commit 530125828e
17 changed files with 426 additions and 47 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"kilo-code": minor
"@kilocode/cli": patch
---
Support mentioning folders in the prompt with @ references, including top-level folder file contents.
+11 -23
View File
@@ -32,7 +32,6 @@ import {
flushPendingSessionRefresh as flushPendingSessionRefreshUtil,
resolveContextDirectory,
resolveWorkspaceDirectory,
mergeFileSearchResults,
SessionStreamScheduler,
type SessionRefreshContext,
} from "./kilo-provider-utils"
@@ -47,6 +46,7 @@ import { retry } from "./services/cli-backend/retry"
import { slimPart, slimParts } from "./kilo-provider/slim-metadata"
import { handleContinueInWorktree } from "./kilo-provider/continue-worktree"
import { parseMessageFiles, type MessageFile } from "./kilo-provider/message-files"
import { handleFileSearch } from "./kilo-provider/file-search"
import { getTerminalContents } from "./services/terminal/context"
import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session"
import { childID } from "./kilo-provider/task-session"
@@ -815,29 +815,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
)
break
}
case "requestFileSearch": {
const sdkClient = this.client
if (sdkClient) {
const dir = this.getWorkspaceDirectory(this.currentSession?.id)
const openPaths = dir ? await this.getOpenTabPaths(dir) : new Set<string>()
void sdkClient.find
.files({ query: message.query, directory: dir, type: "file", limit: 50 }, { throwOnError: true })
.then(({ data: paths }) => {
const uri = vscode.window.activeTextEditor?.document.uri
const active =
uri?.scheme === "file" && dir ? path.relative(dir, uri.fsPath).replaceAll("\\", "/") : undefined
const result = mergeFileSearchResults({ query: message.query, backend: paths, open: openPaths, active })
this.postMessage({ type: "fileSearchResult", paths: result, dir, requestId: message.requestId })
})
.catch((error: unknown) => {
console.error("[Kilo New] File search failed:", error)
this.postMessage({ type: "fileSearchResult", paths: [], dir, requestId: message.requestId })
})
} else {
this.postMessage({ type: "fileSearchResult", paths: [], dir: "", requestId: message.requestId })
}
case "requestFileSearch":
await handleFileSearch({
client: this.client,
message,
current: this.currentSession?.id,
context: this.contextSessionID,
dir: (id) => this.getWorkspaceDirectory(id),
open: (dir) => this.getOpenTabPaths(dir),
post: (msg) => this.postMessage(msg),
})
break
}
case "requestTerminalContext":
void this.handleTerminalContext(message.requestId)
break
@@ -265,6 +265,9 @@ export class AgentManagerProvider implements Disposable {
private async onMessage(msg: Record<string, unknown>): Promise<Record<string, unknown> | null> {
if (this.prBridge.handleMessage(msg)) return null
if (msg.type === "requestFileSearch" && typeof msg.sessionID !== "string" && this.activeSessionId) {
return { ...msg, sessionID: this.activeSessionId }
}
const m = msg as unknown as AgentManagerInMessage
const worktree = await this.onWorktreeMessage(m)
@@ -98,6 +98,8 @@ function createHarness() {
const manager = Object.create(AgentManagerProvider.prototype) as {
host: Host
panel: { sessions: { registerSession: ReturnType<typeof vi.fn> } } | undefined
prBridge: { handleMessage: ReturnType<typeof vi.fn> }
activeSessionId: string | undefined
stateReady: Promise<void> | undefined
createWorktreeOnDisk: ReturnType<typeof vi.fn>
runSetupScriptForWorktree: ReturnType<typeof vi.fn>
@@ -107,6 +109,7 @@ function createHarness() {
notifyWorktreeReady: ReturnType<typeof vi.fn>
log: ReturnType<typeof vi.fn>
onCreateWorktree: () => Promise<null>
onMessage: (msg: Record<string, unknown>) => Promise<Record<string, unknown> | null>
}
manager.host = host
@@ -115,6 +118,8 @@ function createHarness() {
registerSession: vi.fn(),
},
}
manager.prBridge = { handleMessage: vi.fn().mockReturnValue(false) }
manager.activeSessionId = undefined
manager.stateReady = Promise.resolve()
manager.createWorktreeOnDisk = vi.fn()
manager.runSetupScriptForWorktree = vi.fn().mockResolvedValue(undefined)
@@ -169,4 +174,13 @@ describe("AgentManagerProvider worktree creation", () => {
expect(manager.createWorktreeOnDisk).toHaveBeenCalledTimes(1)
})
it("routes file search through the active worktree session", async () => {
const manager = createHarness()
manager.activeSessionId = "session-wt"
const result = await manager.onMessage({ type: "requestFileSearch", query: "src", requestId: "r1" })
expect(result).toEqual({ type: "requestFileSearch", query: "src", requestId: "r1", sessionID: "session-wt" })
})
})
@@ -564,12 +564,16 @@ export function mergeFileSearchResults(input: {
open: Set<string>
active?: string
}): string[] {
const query = input.query.trim().toLowerCase()
const norm = (p: string) => p.replaceAll("\\", "/")
const query = norm(input.query).trim().toLowerCase()
const open = new Set([...input.open].map(norm))
const active = input.active ? norm(input.active) : undefined
const backend = input.backend.map(norm)
const ok = (p: string) => !query || p.toLowerCase().includes(query)
const tabs =
input.active && input.open.has(input.active) && ok(input.active)
? [input.active, ...[...input.open].filter((p) => p !== input.active && ok(p))]
: [...input.open].filter(ok)
active && open.has(active) && ok(active)
? [active, ...[...open].filter((p) => p !== active && ok(p))]
: [...open].filter(ok)
const seen = new Set(tabs)
return [...tabs, ...input.backend.filter((p) => !seen.has(p))]
return [...tabs, ...backend.filter((p) => !seen.has(p))]
}
@@ -0,0 +1,45 @@
export type FileSearchItem = { path: string; type: "file" | "folder" }
const normalize = (p: string) => p.replaceAll("\\", "/")
const trim = (p: string) => normalize(p).replace(/\/+$/, "")
function base(p: string): string {
const clean = trim(p)
return clean.split("/").pop() ?? clean
}
function rank(query: string, p: string): number {
const clean = trim(p).toLowerCase()
const name = base(p).toLowerCase()
if (clean === query || name === query) return 0
if (name.startsWith(query) || (query.includes("/") && clean.startsWith(query))) return 1
if (name.includes(query)) return 2
if (clean.includes(query)) return 3
return 4
}
export function mergeFileSearchItems(input: { query: string; files: string[]; folders: string[] }): FileSearchItem[] {
const query = normalize(input.query).trim().toLowerCase()
const files = input.files.map((p) => ({ path: normalize(p), type: "file" as const }))
// Dedup folders against themselves; a file and a folder that share a stem are distinct entries.
const seen = new Set<string>()
const folders = input.folders
.filter((p) => {
const key = trim(p)
if (seen.has(key)) return false
seen.add(key)
return true
})
.map((p, index) => ({
item: { path: normalize(p), type: "folder" as const },
index,
rank: query ? rank(query, p) : 4,
}))
if (!query) return [...files, ...folders.map((x) => x.item)]
const sorted = [...folders].sort((a, b) => a.rank - b.rank || a.index - b.index)
const boosted = sorted.filter((x) => x.rank <= 1).map((x) => x.item)
const rest = sorted.filter((x) => x.rank > 1).map((x) => x.item)
return [...boosted, ...files, ...rest]
}
@@ -0,0 +1,54 @@
import * as path from "path"
import * as vscode from "vscode"
import type { KiloClient } from "@kilocode/sdk/v2/client"
import { mergeFileSearchResults } from "../kilo-provider-utils"
import { mergeFileSearchItems } from "./file-search-items"
type Message = {
query: string
requestId: string
sessionID?: string
}
type Input = {
client: KiloClient | null
message: Message
current?: string
context?: string
dir: (id?: string) => string
open: (dir: string) => Promise<Set<string>>
post: (message: unknown) => void
}
export async function handleFileSearch(input: Input): Promise<void> {
const client = input.client
if (!client) {
input.post({ type: "fileSearchResult", paths: [], items: [], dir: "", requestId: input.message.requestId })
return
}
const id = input.message.sessionID ?? input.current ?? input.context
const dir = input.dir(id)
const open = dir ? await input.open(dir) : new Set<string>()
const query = input.message.query
void Promise.allSettled([
client.find.files({ query, directory: dir, type: "file", limit: 50 }, { throwOnError: true }),
client.find.files({ query, directory: dir, type: "directory", limit: 50 }, { throwOnError: true }),
]).then(([fileRes, folderRes]) => {
const files = settled(fileRes, "file")
const folders = settled(folderRes, "folder")
const uri = vscode.window.activeTextEditor?.document.uri
const rel = uri?.scheme === "file" && dir ? path.relative(dir, uri.fsPath) : undefined
const active = rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.replaceAll("\\", "/") : undefined
const result = mergeFileSearchResults({ query, backend: files, open, active })
const items = mergeFileSearchItems({ query, files: result, folders })
input.post({ type: "fileSearchResult", paths: result, items, dir, requestId: input.message.requestId })
})
}
function settled(result: PromiseSettledResult<{ data: string[] }>, kind: "file" | "folder"): string[] {
if (result.status === "fulfilled") return result.value.data
console.error(`[Kilo New] File search (${kind}) failed:`, result.reason)
return []
}
@@ -50,6 +50,11 @@ describe("buildMentionResults", () => {
const result = buildMentionResults("src", ["src/index.ts"])
expect(result.map((item) => item.type)).toEqual(["file"])
})
it("includes folder results", () => {
const result = buildMentionResults("src", [{ path: "src", type: "folder" }])
expect(result).toEqual([{ type: "folder", value: "src" }])
})
})
describe("syncMentionedPaths", () => {
@@ -13,6 +13,7 @@ import {
getConfigErrorDetails,
type ProviderInfo,
} from "../../src/kilo-provider-utils"
import { mergeFileSearchItems } from "../../src/kilo-provider/file-search-items"
import type { CloudSessionMessage } from "../../src/services/cli-backend/types"
import type {
Session,
@@ -606,6 +607,47 @@ describe("mapCloudSessionMessage", () => {
})
})
describe("mergeFileSearchItems", () => {
it("puts exact folder matches before file matches", () => {
const result = mergeFileSearchItems({
query: "script",
files: ["script/hooks", "script/release", "script/beta.ts"],
folders: ["script/", "script/run-script/"],
})
expect(result).toEqual([
{ path: "script/", type: "folder" },
{ path: "script/hooks", type: "file" },
{ path: "script/release", type: "file" },
{ path: "script/beta.ts", type: "file" },
{ path: "script/run-script/", type: "folder" },
])
})
it("keeps file ordering before non-prefix folder matches", () => {
const result = mergeFileSearchItems({
query: "test",
files: ["src/test.ts"],
folders: ["src/latest/"],
})
expect(result).toEqual([
{ path: "src/test.ts", type: "file" },
{ path: "src/latest/", type: "folder" },
])
})
it("normalizes Windows separators for matching and output", () => {
const result = mergeFileSearchItems({
query: "kilo-vscode",
files: ["packages\\kilo-vscode\\src\\KiloProvider.ts"],
folders: ["packages\\kilo-vscode\\"],
})
expect(result).toEqual([
{ path: "packages/kilo-vscode/", type: "folder" },
{ path: "packages/kilo-vscode/src/KiloProvider.ts", type: "file" },
])
})
})
describe("mergeFileSearchResults", () => {
it("returns backend results when no open files", () => {
const result = mergeFileSearchResults({
@@ -699,6 +741,16 @@ describe("mergeFileSearchResults", () => {
})
expect(result).toEqual(["src/utils/path.ts", "src/index.ts"])
})
it("normalizes backslash paths before filtering and deduping", () => {
const result = mergeFileSearchResults({
query: "utils/path",
backend: ["src\\utils\\path.ts"],
open: new Set(["src/utils/path.ts"]),
active: "src\\utils\\path.ts",
})
expect(result).toEqual(["src/utils/path.ts"])
})
})
describe("getErrorMessage", () => {
@@ -65,7 +65,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const vscode = useVSCode()
const worktree = useWorktreeMode()
const dialog = useDialog()
const mention = useFileMention(vscode)
const mention = useFileMention(
vscode,
() => session.currentSessionID() ?? props.pendingSessionID ?? session.draftSessionID(),
)
const terminal = useTerminalContext(vscode)
const excluded = worktree ? new Set(["sessions"]) : undefined
const slash = useSlashCommand(vscode, excluded)
@@ -715,7 +718,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="file-mention-dropdown" ref={dropdownRef}>
<Show
when={mention.mentionResults().length > 0}
fallback={<div class="file-mention-empty">No files found</div>}
fallback={<div class="file-mention-empty">No files or folders found</div>}
>
<For each={mention.mentionResults()}>
{(item, index) => (
@@ -736,8 +739,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</>
) : (
<>
<FileIcon node={{ path: item.value, type: "file" }} class="file-mention-icon" />
<span class="file-mention-name">{fileName(item.value)}</span>
<FileIcon
node={{ path: item.value, type: item.type === "folder" ? "directory" : "file" }}
class="file-mention-icon"
/>
<span class="file-mention-name">
{item.type === "folder" ? `${fileName(item.value)}/` : fileName(item.value)}
</span>
<span class="file-mention-dir">{dirName(item.value)}</span>
</>
)}
@@ -1,4 +1,4 @@
import type { FileAttachment } from "../types/messages"
import type { FileAttachment, FileSearchItem } from "../types/messages"
import { TERMINAL_MENTION } from "./terminal-context-utils"
export const AT_PATTERN = /(?:^|\s)@(\S*)$/
@@ -6,6 +6,7 @@ export const AT_PATTERN = /(?:^|\s)@(\S*)$/
export type MentionResult =
| { type: "terminal"; value: typeof TERMINAL_MENTION; label: string; description: string }
| { type: "file"; value: string }
| { type: "folder"; value: string }
export const TERMINAL_RESULT: MentionResult = {
type: "terminal",
@@ -27,8 +28,13 @@ export function getTerminalMentionResult(query: string): MentionResult[] {
return [TERMINAL_RESULT]
}
export function buildMentionResults(query: string, paths: string[]): MentionResult[] {
return [...getTerminalMentionResult(query), ...paths.map((path) => ({ type: "file" as const, value: path }))]
export function buildMentionResults(query: string, items: Array<FileSearchItem | string>): MentionResult[] {
const results: MentionResult[] = items.map((item) => {
if (typeof item === "string") return { type: "file", value: item }
if (item.type === "folder") return { type: "folder", value: item.path }
return { type: "file", value: item.path }
})
return [...getTerminalMentionResult(query), ...results]
}
/**
@@ -50,7 +56,7 @@ export function syncMentionedPaths(prev: Set<string>, text: string): Set<string>
}
/**
* Replace the @mention pattern before the cursor with the selected file path.
* Replace the @mention pattern before the cursor with the selected path.
* Returns the new text string.
*/
export function buildTextAfterMentionSelect(before: string, after: string, path: string): string {
@@ -42,7 +42,7 @@ export interface FileMention {
addPaths: (paths: string[], cwd: string) => void
}
export function useFileMention(vscode: VSCodeContext): FileMention {
export function useFileMention(vscode: VSCodeContext, sessionID?: Accessor<string | undefined>): FileMention {
const [mentionedPaths, setMentionedPaths] = createSignal<Set<string>>(new Set())
const [mentionQuery, setMentionQuery] = createSignal<string | null>(null)
const [mentionResults, setMentionResults] = createSignal<MentionResult[]>([])
@@ -60,10 +60,10 @@ export function useFileMention(vscode: VSCodeContext): FileMention {
const unsubscribe = vscode.onMessage((message) => {
if (message.type !== "fileSearchResult") return
const result = message as { type: "fileSearchResult"; paths: string[]; dir: string; requestId: string }
if (result.requestId === `file-search-${fileSearchCounter}`) {
workspaceDir = result.dir
setMentionResults(buildMentionResults(mentionQuery() ?? "", result.paths))
if (message.requestId === `file-search-${fileSearchCounter}`) {
const items = message.items ?? message.paths.map((path) => ({ path, type: "file" as const }))
workspaceDir = message.dir
setMentionResults(buildMentionResults(mentionQuery() ?? "", items))
setMentionIndex(0)
}
})
@@ -77,7 +77,13 @@ export function useFileMention(vscode: VSCodeContext): FileMention {
if (fileSearchTimer) clearTimeout(fileSearchTimer)
fileSearchTimer = setTimeout(() => {
fileSearchCounter++
vscode.postMessage({ type: "requestFileSearch", query, requestId: `file-search-${fileSearchCounter}` })
const id = sessionID?.()
vscode.postMessage({
type: "requestFileSearch",
query,
requestId: `file-search-${fileSearchCounter}`,
...(id ? { sessionID: id } : {}),
})
}, FILE_SEARCH_DEBOUNCE_MS)
}
@@ -110,7 +116,8 @@ export function useFileMention(vscode: VSCodeContext): FileMention {
textarea.setSelectionRange(pos, pos)
textarea.focus()
if (result.type === "file") setMentionedPaths((prev) => new Set([...prev, result.value]))
if (result.type === "file" || result.type === "folder")
setMentionedPaths((prev) => new Set([...prev, result.value]))
closeMention()
onSelect?.()
}
@@ -749,9 +749,15 @@ export interface ChatCompletionResultMessage {
requestId: string
}
export interface FileSearchItem {
path: string
type: "file" | "folder"
}
export interface FileSearchResultMessage {
type: "fileSearchResult"
paths: string[]
items?: FileSearchItem[]
dir: string
requestId: string
}
@@ -1896,6 +1902,7 @@ export interface RequestFileSearchMessage {
type: "requestFileSearch"
query: string
requestId: string
sessionID?: string
}
export interface RequestTerminalContextMessage {
@@ -0,0 +1,47 @@
import { Effect } from "effect"
import { lstat } from "fs/promises"
import * as path from "path"
import { AppFileSystem } from "../../filesystem"
import { Instance } from "../../project/instance"
import { isBinaryFile, lines } from "../../tool/read"
const LIMIT = 2000
const CONCURRENCY = 8
export type DirectoryFile = {
filepath: string
content: string
}
export const readDirectoryFiles = Effect.fn("KiloReadDirectory.files")(function* (
fs: AppFileSystem.Interface,
filepath: string,
items: string[],
) {
const entries = yield* fs.readDirectoryEntries(filepath).pipe(Effect.catch(() => Effect.succeed([])))
const types = new Map(entries.map((entry) => [entry.name, entry.type]))
const files = yield* Effect.forEach(
items.filter((item) => !item.endsWith("/") && types.get(item) === "file"),
Effect.fnUntraced(function* (item) {
const child = path.join(filepath, item)
const info = yield* Effect.promise(() => lstat(child)).pipe(Effect.catch(() => Effect.void))
if (!info?.isFile()) return
const binary = yield* Effect.promise(() => isBinaryFile(child, info.size)).pipe(
Effect.catch(() => Effect.succeed(true)),
)
if (binary) return
const file = yield* Effect.promise(() => lines(child, { limit: LIMIT, offset: 1 })).pipe(
Effect.catch(() => Effect.void),
)
if (!file) return
const rel = path.relative(Instance.directory, child).replaceAll("\\", "/")
const note = file.cut || file.more ? "\n\n(File truncated)" : ""
return {
filepath: child,
content: `<file_content path="${rel}">\n${file.raw.join("\n")}${note}\n</file_content>`,
}
}),
{ concurrency: CONCURRENCY },
)
return files.filter((item): item is DirectoryFile => item !== undefined)
})
+1 -1
View File
@@ -1142,7 +1142,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (part.mime === "application/x-directory") {
const args = { filePath: filepath }
const exit = yield* execRead(args).pipe(Effect.exit)
const exit = yield* execRead(args, { includeDirectoryFiles: true }).pipe(Effect.exit) // kilocode_change inline folder files
if (Exit.isFailure(exit)) {
const error = Cause.squash(exit.cause)
log.error("failed to read directory", { error })
+20 -3
View File
@@ -12,6 +12,9 @@ import DESCRIPTION from "./read.txt"
import { Instance } from "../project/instance"
import { assertExternalDirectoryEffect } from "./external-directory"
import { Instruction } from "../session/instruction"
// kilocode_change start
import { readDirectoryFiles } from "../kilocode/tool/read-directory"
// kilocode_change end
const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
@@ -124,6 +127,11 @@ export const ReadTool = Tool.defineEffect(
const start = offset - 1
const sliced = items.slice(start, start + limit)
const truncated = start + sliced.length < items.length
// kilocode_change start
const expand = Boolean(ctx.extra?.["includeDirectoryFiles"])
const loaded = expand ? yield* readDirectoryFiles(fs, filepath, sliced) : []
const content = loaded.map((item) => item.content).join("\n\n")
// kilocode_change end
return {
title,
@@ -136,11 +144,16 @@ export const ReadTool = Tool.defineEffect(
? `\n(Showing ${sliced.length} of ${items.length} entries. Use 'offset' parameter to read beyond entry ${offset + sliced.length})`
: `\n(${items.length} entries)`,
`</entries>`,
// kilocode_change start
...(content ? [`\n${content}`] : []),
// kilocode_change end
].join("\n"),
metadata: {
preview: sliced.slice(0, 20).join("\n"),
truncated,
loaded: [] as string[],
// kilocode_change start
loaded: loaded.map((item) => item.filepath),
// kilocode_change end
},
}
}
@@ -225,7 +238,9 @@ export const ReadTool = Tool.defineEffect(
}),
)
async function lines(filepath: string, opts: { limit: number; offset: number }) {
// kilocode_change start
export async function lines(filepath: string, opts: { limit: number; offset: number }) {
// kilocode_change end
const stream = createReadStream(filepath, { encoding: "utf8" })
const rl = createInterface({
input: stream,
@@ -269,7 +284,9 @@ async function lines(filepath: string, opts: { limit: number; offset: number })
return { raw, count, cut, more, offset: opts.offset }
}
async function isBinaryFile(filepath: string, fileSize: number): Promise<boolean> {
// kilocode_change start
export async function isBinaryFile(filepath: string, fileSize: number): Promise<boolean> {
// kilocode_change end
const ext = path.extname(filepath).toLowerCase()
// binary check for common non-text extensions
switch (ext) {
@@ -0,0 +1,116 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { symlink } from "fs/promises"
import path from "path"
import { Agent } from "../../src/agent/agent"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { AppFileSystem } from "../../src/filesystem"
import { FileTime } from "../../src/file/time"
import { LSP } from "../../src/lsp"
import { Instruction } from "../../src/session/instruction"
import { MessageID, SessionID } from "../../src/session/schema"
import { ReadTool } from "../../src/tool/read"
import { Tool } from "../../src/tool/tool"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const baseCtx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
callID: "",
agent: "code",
abort: AbortSignal.any([]),
messages: [],
metadata: () => {},
ask: async () => {},
}
const expandCtx = { ...baseCtx, extra: { includeDirectoryFiles: true } }
const it = testEffect(
Layer.mergeAll(
Agent.defaultLayer,
AppFileSystem.defaultLayer,
CrossSpawnSpawner.defaultLayer,
FileTime.defaultLayer,
Instruction.defaultLayer,
LSP.defaultLayer,
),
)
const init = Effect.fn("ReadDirectoryTest.init")(function* () {
const info = yield* ReadTool
return yield* Effect.promise(() => info.init())
})
const run = Effect.fn("ReadDirectoryTest.run")(function* (
args: Tool.InferParameters<typeof ReadTool>,
ctx = expandCtx,
) {
const tool = yield* init()
return yield* Effect.promise(() => tool.execute(args, ctx))
})
const exec = Effect.fn("ReadDirectoryTest.exec")(function* (
dir: string,
args: Tool.InferParameters<typeof ReadTool>,
ctx = expandCtx,
) {
return yield* provideInstance(dir)(run(args, ctx))
})
const put = Effect.fn("ReadDirectoryTest.put")(function* (p: string, content: string | Uint8Array) {
const fs = yield* AppFileSystem.Service
yield* fs.writeWithDirs(p, content)
})
describe("kilocode directory reads", () => {
it.live("includes top-level file contents for directory reads", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "folder", "a.txt"), "alpha")
yield* put(path.join(dir, "folder", "nested", "b.txt"), "beta")
yield* put(path.join(dir, "folder", "binary.bin"), new Uint8Array([0, 1, 2]))
const result = yield* exec(dir, { filePath: path.join(dir, "folder") })
expect(result.output).toContain("a.txt")
expect(result.output).toContain('<file_content path="folder/a.txt">\nalpha\n</file_content>')
expect(result.output).not.toContain('<file_content path="folder/nested/b.txt">')
expect(result.output).not.toContain('<file_content path="folder/binary.bin">')
expect(result.metadata.loaded).toContain(path.join(dir, "folder", "a.txt"))
}),
)
it.live("skips content inlining without the kilo flag", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "folder", "a.txt"), "alpha")
const result = yield* exec(dir, { filePath: path.join(dir, "folder") }, baseCtx)
expect(result.output).toContain("a.txt")
expect(result.output).not.toContain('<file_content path="folder/a.txt">')
expect(result.metadata.loaded).toEqual([])
}),
)
if (process.platform !== "win32") {
it.live("skips symlinked top-level files", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const outer = yield* tmpdirScoped()
yield* put(path.join(dir, "folder", "a.txt"), "alpha")
yield* put(path.join(outer, "secret.txt"), "secret")
yield* Effect.promise(() => symlink(path.join(outer, "secret.txt"), path.join(dir, "folder", "secret.txt")))
const result = yield* exec(dir, { filePath: path.join(dir, "folder") })
expect(result.output).toContain("secret.txt")
expect(result.output).not.toContain('<file_content path="folder/secret.txt">')
expect(result.output).not.toContain("secret\n</file_content>")
expect(result.metadata.loaded).not.toContain(path.join(dir, "folder", "secret.txt"))
}),
)
}
})