Merge remote-tracking branch 'origin/main' into feat/cli-local-run

# Conflicts:
#	package.json
This commit is contained in:
Alex Alecu
2026-04-27 15:08:21 +03:00
195 changed files with 9198 additions and 4786 deletions
+1 -1
View File
@@ -575,7 +575,7 @@ test("legacy tools config converts to permissions", async () => {
})
})
test("legacy tools config maps write/edit/patch/multiedit to edit permission", async () => {
test("legacy tools config maps write/edit/patch to edit permission", async () => {
await using tmp = await tmpdir({
config: {
agent: {
+60
View File
@@ -0,0 +1,60 @@
// kilocode_change - new file
import { expect, test } from "bun:test"
import { cliCommand } from "../../src/cli/cmd/pr"
test("cliCommand uses the current script when argv[1] is a file path", () => {
const result = cliCommand({
execPath: "/usr/bin/node",
argv: ["/usr/bin/node", "/tmp/kilo.js", "pr", "1"],
exists: (file) => file === "/tmp/kilo.js",
})
expect(result).toEqual(["/usr/bin/node", "/tmp/kilo.js"])
})
test("cliCommand falls back to execPath when argv[1] is a subcommand", () => {
const result = cliCommand({
execPath: "/usr/local/bin/kilo",
argv: ["/usr/local/bin/kilo", "pr", "1"],
exists: () => false,
})
expect(result).toEqual(["/usr/local/bin/kilo"])
})
test("cliCommand ignores subcommand token even when it exists on disk", () => {
const result = cliCommand({
execPath: "/usr/local/bin/kilo",
argv: ["/usr/local/bin/kilo", "pr", "1"],
exists: (file) => file === "pr",
})
expect(result).toEqual(["/usr/local/bin/kilo"])
})
test("cliCommand falls back to execPath when argv[1] is missing", () => {
const result = cliCommand({
execPath: "/usr/local/bin/kilo",
argv: ["/usr/local/bin/kilo"],
exists: () => false,
})
expect(result).toEqual(["/usr/local/bin/kilo"])
})
test("cliCommand falls back to execPath for bun virtual script paths", () => {
const unix = cliCommand({
execPath: "/tmp/kilo",
argv: ["/tmp/kilo", "/$bunfs/root/src/index.js", "pr", "1"],
exists: () => true,
})
const win = cliCommand({
execPath: "C:/tmp/kilo.exe",
argv: ["C:/tmp/kilo.exe", "B:/~BUN/root/src/index.js", "pr", "1"],
exists: () => true,
})
expect(unix).toEqual(["/tmp/kilo"])
expect(win).toEqual(["C:/tmp/kilo.exe"])
})
+24 -43
View File
@@ -1494,35 +1494,6 @@ test("migrates legacy patch tool to edit permission", async () => {
})
})
test("migrates legacy multiedit tool to edit permission", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "kilo.json"),
JSON.stringify({
$schema: "https://app.kilo.ai/config.json",
agent: {
test: {
tools: {
multiedit: false,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
expect(config.agent?.["test"]?.permission).toEqual({
edit: "deny",
})
},
})
})
test("migrates mixed legacy tools config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -1591,11 +1562,19 @@ test("merges legacy tools with existing permission config", async () => {
})
})
// kilocode_change start — isolate from global config to prevent cross-test contamination
// (migrateBashPermission may write permission.bash to a global config file created by other
// test files running in parallel, which mergeDeep then prepends to the project permission keys)
test("permission config preserves key order", async () => {
test("permission config canonicalises known keys first, preserves rest-key insertion order", async () => {
// ConfigPermission.Info is a StructWithRest schema — the decoder reorders
// keys into declaration-order for known permission names (edit, read,
// todowrite, external_directory are declared in `config/permission.ts`),
// followed by rest keys in the user's insertion order.
//
// Rule precedence is NOT affected by this reordering: `Permission.fromConfig`
// sorts wildcards before specifics before iterating. See the
// "fromConfig - specific key beats wildcard regardless of JSON key order"
// test in test/permission/next.test.ts for the behavioural guarantee.
// kilocode_change start — isolate from global config to prevent cross-test contamination
// (migrateBashPermission may write permission.bash to a global config file created by other
// test files running in parallel, which mergeDeep then prepends to the project permission keys)
await using globalTmp = await tmpdir()
const prev = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
@@ -1629,12 +1608,15 @@ test("permission config preserves key order", async () => {
fn: async () => {
const config = await load()
expect(Object.keys(config.permission!)).toEqual([
"*",
"edit",
"write",
"external_directory",
// known fields that the user provided, in declaration order from
// config/permission.ts (read, edit, ..., external_directory, todowrite)
"read",
"edit",
"external_directory",
"todowrite",
// rest keys (not in the known list), in user's insertion order
"*",
"write",
"thoughts_*",
"reasoning_model_*",
"tools_*",
@@ -1649,7 +1631,6 @@ test("permission config preserves key order", async () => {
}
// kilocode_change end
})
// kilocode_change end
// MCP config merging tests
@@ -2339,7 +2320,7 @@ describe("KILO_CONFIG_CONTENT token substitution", () => {
test("parseManagedPlist strips MDM metadata keys", async () => {
const config = ConfigParse.schema(
Config.Info,
Config.Info.zod,
ConfigParse.jsonc(
await ConfigManaged.parseManagedPlist(
JSON.stringify({
@@ -2367,7 +2348,7 @@ test("parseManagedPlist strips MDM metadata keys", async () => {
test("parseManagedPlist parses server settings", async () => {
const config = ConfigParse.schema(
Config.Info,
Config.Info.zod,
ConfigParse.jsonc(
await ConfigManaged.parseManagedPlist(
JSON.stringify({
@@ -2387,7 +2368,7 @@ test("parseManagedPlist parses server settings", async () => {
test("parseManagedPlist parses permission rules", async () => {
const config = ConfigParse.schema(
Config.Info,
Config.Info.zod,
ConfigParse.jsonc(
await ConfigManaged.parseManagedPlist(
JSON.stringify({
@@ -2417,7 +2398,7 @@ test("parseManagedPlist parses permission rules", async () => {
test("parseManagedPlist parses enabled_providers", async () => {
const config = ConfigParse.schema(
Config.Info,
Config.Info.zod,
ConfigParse.jsonc(
await ConfigManaged.parseManagedPlist(
JSON.stringify({
@@ -2434,7 +2415,7 @@ test("parseManagedPlist parses enabled_providers", async () => {
test("parseManagedPlist handles empty config", async () => {
const config = ConfigParse.schema(
Config.Info,
Config.Info.zod,
ConfigParse.jsonc(
await ConfigManaged.parseManagedPlist(JSON.stringify({ $schema: "https://opencode.ai/config.json" })),
"test:mobileconfig",
@@ -169,7 +169,9 @@ describe("cross-spawn spawner", () => {
'process.stderr.write("stderr\\n", done)',
].join("\n"),
)
const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)])
const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], {
concurrency: 2,
})
expect(stdout).toBe("stdout")
expect(stderr).toBe("stderr")
}),
+4 -2
View File
@@ -676,7 +676,8 @@ describe("file/index Filesystem patterns", () => {
})
})
describe("search()", () => {
// kilocode_change - skip on windows: address windows ci failures #9496
describe.skipIf(process.platform === "win32")("search()", () => {
async function setupSearchableRepo() {
const tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "index.ts"), "code", "utf-8")
@@ -893,7 +894,8 @@ describe("file/index Filesystem patterns", () => {
})
})
describe("InstanceState isolation", () => {
// kilocode_change - skip on windows: address windows ci failures #9496
describe.skipIf(process.platform === "win32")("InstanceState isolation", () => {
test("two directories get independent file caches", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
+2 -1
View File
@@ -9,7 +9,8 @@ import { Ripgrep } from "../../src/file/ripgrep"
const run = <A>(effect: Effect.Effect<A, unknown, Ripgrep.Service>) =>
effect.pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
describe("file.ripgrep", () => {
// kilocode_change - skip on windows: address windows ci failures #9496
describe.skipIf(process.platform === "win32")("file.ripgrep", () => {
test("defaults to include hidden", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -1,7 +1,23 @@
// Simple JSON-RPC 2.0 LSP-like fake server over stdio
// Implements a minimal LSP handshake and triggers a request upon notification
let nextId = 1
let readBuffer = Buffer.alloc(0)
let lastChange = null
let initializeParams = null
let diagnosticRequestCount = 0
let registeredCapability = false
const pendingClientRequests = new Map()
let pullConfig = {
delayMs: 0,
registerOn: undefined,
registrations: [],
documentDiagnostics: [],
documentDiagnosticsByIdentifier: {},
documentDelayMsByIdentifier: {},
workspaceDiagnostics: [],
workspaceDiagnosticsByIdentifier: {},
workspaceDelayMsByIdentifier: {},
}
function encode(message) {
const json = JSON.stringify(message)
@@ -14,29 +30,19 @@ function decodeFrames(buffer) {
let idx
while ((idx = buffer.indexOf("\r\n\r\n")) !== -1) {
const header = buffer.slice(0, idx).toString("utf8")
const m = /Content-Length:\s*(\d+)/i.exec(header)
const len = m ? parseInt(m[1], 10) : 0
const match = /Content-Length:\s*(\d+)/i.exec(header)
const length = match ? parseInt(match[1], 10) : 0
const bodyStart = idx + 4
const bodyEnd = bodyStart + len
const bodyEnd = bodyStart + length
if (buffer.length < bodyEnd) break
const body = buffer.slice(bodyStart, bodyEnd).toString("utf8")
results.push(body)
results.push(buffer.slice(bodyStart, bodyEnd).toString("utf8"))
buffer = buffer.slice(bodyEnd)
}
return { messages: results, rest: buffer }
}
let readBuffer = Buffer.alloc(0)
process.stdin.on("data", (chunk) => {
readBuffer = Buffer.concat([readBuffer, chunk])
const { messages, rest } = decodeFrames(readBuffer)
readBuffer = rest
for (const m of messages) handle(m)
})
function send(msg) {
process.stdout.write(encode(msg))
function send(message) {
process.stdout.write(encode(message))
}
function sendRequest(method, params) {
@@ -45,6 +51,50 @@ function sendRequest(method, params) {
return id
}
function sendResponse(id, result) {
send({ jsonrpc: "2.0", id, result })
}
function sendNotification(method, params) {
send({ jsonrpc: "2.0", method, params })
}
function maybeRegister(method) {
if (pullConfig.registerOn !== method || registeredCapability) return
registeredCapability = true
sendRequest("client/registerCapability", {
registrations: pullConfig.registrations.map((registration, index) => ({
id: registration.id ?? `pull-${index}`,
method: registration.method ?? "textDocument/diagnostic",
registerOptions: registration.registerOptions ?? registration,
})),
})
}
function delayed(id, result, delayMs = pullConfig.delayMs) {
if (!delayMs) {
sendResponse(id, result)
return
}
setTimeout(() => sendResponse(id, result), delayMs)
}
function diagnosticsForIdentifier(identifier) {
return pullConfig.documentDiagnosticsByIdentifier[identifier] ?? pullConfig.documentDiagnostics
}
function workspaceDiagnosticsForIdentifier(identifier) {
return pullConfig.workspaceDiagnosticsByIdentifier[identifier] ?? pullConfig.workspaceDiagnostics
}
function documentDelayForIdentifier(identifier) {
return pullConfig.documentDelayMsByIdentifier[identifier] ?? pullConfig.delayMs
}
function workspaceDelayForIdentifier(identifier) {
return pullConfig.workspaceDelayMsByIdentifier[identifier] ?? pullConfig.delayMs
}
function handle(raw) {
let data
try {
@@ -52,24 +102,148 @@ function handle(raw) {
} catch {
return
}
if (typeof data.method === "undefined" && typeof data.id !== "undefined") {
const pending = pendingClientRequests.get(data.id)
if (!pending) return
pendingClientRequests.delete(data.id)
sendResponse(pending, data.result ?? null)
return
}
if (data.method === "initialize") {
send({ jsonrpc: "2.0", id: data.id, result: { capabilities: {} } })
initializeParams = data.params
sendResponse(data.id, {
capabilities: {
textDocumentSync: {
change: 2,
},
},
})
return
}
if (data.method === "initialized") {
if (data.method === "test/get-initialize-params") {
sendResponse(data.id, initializeParams)
return
}
if (data.method === "workspace/didChangeConfiguration") {
if (data.method === "test/request-configuration") {
const id = sendRequest("workspace/configuration", data.params)
pendingClientRequests.set(id, data.id)
return
}
if (data.method === "initialized" || data.method === "workspace/didChangeConfiguration") {
return
}
if (data.method === "textDocument/didOpen") {
maybeRegister("didOpen")
return
}
if (data.method === "textDocument/didChange") {
lastChange = data.params
maybeRegister("didChange")
return
}
if (data.method === "test/trigger") {
const method = data.params && data.params.method
if (method === "client/registerCapability") {
sendRequest(method, {
registrations: [
{
id: "test-diagnostic-registration",
method: "textDocument/diagnostic",
registerOptions: { identifier: "syntax" },
},
],
})
return
}
if (method === "client/unregisterCapability") {
sendRequest(method, {
unregisterations: [{ id: "test-diagnostic-registration", method: "textDocument/diagnostic" }],
})
return
}
if (method) sendRequest(method, {})
return
}
if (typeof data.id !== "undefined") {
// Respond OK to any request from client to keep transport flowing
send({ jsonrpc: "2.0", id: data.id, result: null })
if (data.method === "test/configure-pull-diagnostics") {
pullConfig = {
delayMs: data.params?.delayMs ?? 0,
registerOn: data.params?.registerOn,
registrations: data.params?.registrations ?? [],
documentDiagnostics: data.params?.documentDiagnostics ?? [],
documentDiagnosticsByIdentifier: data.params?.documentDiagnosticsByIdentifier ?? {},
documentDelayMsByIdentifier: data.params?.documentDelayMsByIdentifier ?? {},
workspaceDiagnostics: data.params?.workspaceDiagnostics ?? [],
workspaceDiagnosticsByIdentifier: data.params?.workspaceDiagnosticsByIdentifier ?? {},
workspaceDelayMsByIdentifier: data.params?.workspaceDelayMsByIdentifier ?? {},
}
registeredCapability = false
sendResponse(data.id, null)
return
}
if (data.method === "test/register-configured-pull-diagnostics") {
maybeRegister(undefined)
sendResponse(data.id, null)
return
}
if (data.method === "test/publish-diagnostics") {
sendNotification("textDocument/publishDiagnostics", data.params)
return
}
if (data.method === "test/get-last-change") {
sendResponse(data.id, lastChange)
return
}
if (data.method === "test/get-diagnostic-request-count") {
sendResponse(data.id, diagnosticRequestCount)
return
}
if (data.method === "textDocument/diagnostic") {
diagnosticRequestCount += 1
delayed(
data.id,
{
kind: "full",
items: diagnosticsForIdentifier(data.params?.identifier ?? ""),
},
documentDelayForIdentifier(data.params?.identifier ?? ""),
)
return
}
if (data.method === "workspace/diagnostic") {
diagnosticRequestCount += 1
delayed(
data.id,
{
items: workspaceDiagnosticsForIdentifier(data.params?.identifier ?? ""),
},
workspaceDelayForIdentifier(data.params?.identifier ?? ""),
)
return
}
if (typeof data.id !== "undefined") {
sendResponse(data.id, null)
}
}
process.stdin.on("data", (chunk) => {
readBuffer = Buffer.concat([readBuffer, chunk])
const { messages, rest } = decodeFrames(readBuffer)
readBuffer = rest
for (const message of messages) handle(message)
})
+31 -3
View File
@@ -126,6 +126,24 @@ describe("Format", () => {
it.live("service initializes without error", () => provideTmpdirInstance(() => Format.Service.use(() => Effect.void)))
it.live("file() returns false when no formatter runs", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const file = `${dir}/test.txt`
yield* Effect.promise(() => Bun.write(file, "x"))
const formatted = yield* Format.Service.use((fmt) => fmt.file(file))
expect(formatted).toBe(false)
}),
{
config: {
formatter: false,
},
},
),
)
it.live("status() initializes formatter state per directory", () =>
Effect.gen(function* () {
const a = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status()), {
@@ -219,7 +237,7 @@ describe("Format", () => {
yield* Format.Service.use((fmt) =>
Effect.gen(function* () {
yield* fmt.init()
yield* fmt.file(file)
expect(yield* fmt.file(file)).toBe(true)
}),
)
@@ -229,11 +247,21 @@ describe("Format", () => {
config: {
formatter: {
first: {
command: ["sh", "-c", 'sleep 0.05; v=$(cat "$1"); printf \'%sA\' "$v" > "$1"', "sh", "$FILE"],
command: [
"node",
"-e",
"const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'A')",
"$FILE",
],
extensions: [".seq"],
},
second: {
command: ["sh", "-c", 'v=$(cat "$1"); printf \'%sB\' "$v" > "$1"', "sh", "$FILE"],
command: [
"node",
"-e",
"const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'B')",
"$FILE",
],
extensions: [".seq"],
},
},
@@ -257,7 +257,7 @@ describe("Ask agent tool disabled checks", () => {
})
test("edit tools are disabled", () => {
const tools = ["edit", "write", "patch", "multiedit"]
const tools = ["edit", "write", "patch"]
const result = Permission.disabled(tools, ruleset)
for (const tool of tools) {
expect(result.has(tool)).toBe(true)
@@ -4,27 +4,27 @@ import { Config } from "../../../src/config"
describe("Config.Info experimental.openTelemetry default", () => {
test("defaults to true when experimental is set without openTelemetry", () => {
const parsed = Config.Info.parse({ experimental: {} })
const parsed = Config.Info.zod.parse({ experimental: {} })
expect(parsed.experimental?.openTelemetry).toBe(true)
})
test("defaults to true when openTelemetry is explicitly undefined", () => {
const parsed = Config.Info.parse({ experimental: { openTelemetry: undefined } })
const parsed = Config.Info.zod.parse({ experimental: { openTelemetry: undefined } })
expect(parsed.experimental?.openTelemetry).toBe(true)
})
test("respects explicit false", () => {
const parsed = Config.Info.parse({ experimental: { openTelemetry: false } })
const parsed = Config.Info.zod.parse({ experimental: { openTelemetry: false } })
expect(parsed.experimental?.openTelemetry).toBe(false)
})
test("respects explicit true", () => {
const parsed = Config.Info.parse({ experimental: { openTelemetry: true } })
const parsed = Config.Info.zod.parse({ experimental: { openTelemetry: true } })
expect(parsed.experimental?.openTelemetry).toBe(true)
})
test("experimental stays undefined when not set at all", () => {
const parsed = Config.Info.parse({})
const parsed = Config.Info.zod.parse({})
expect(parsed.experimental).toBeUndefined()
})
})
@@ -14,7 +14,7 @@ import { KilocodeConfig } from "../../src/kilocode/config/config"
describe("Config.Info — null sentinels for custom provider deletes", () => {
it("accepts a null model value inside a provider", () => {
const parsed = Config.Info.safeParse({
const parsed = Config.Info.zod.safeParse({
provider: {
myprovider: {
name: "My Provider",
@@ -28,7 +28,7 @@ describe("Config.Info — null sentinels for custom provider deletes", () => {
})
it("accepts a null variant value inside a model", () => {
const parsed = Config.Info.safeParse({
const parsed = Config.Info.zod.safeParse({
provider: {
myprovider: {
name: "My Provider",
@@ -1,18 +0,0 @@
import { describe, expect, test } from "bun:test"
import { KiloRipgrepStream } from "../../src/kilocode/kilo-ripgrep-stream"
describe("KiloRipgrepStream", () => {
test("drains lines without splitting UTF-8 characters", () => {
const icon = "\u{1f600}"
const bytes = Buffer.from(`src/${icon}.ts\nnext.ts\n`)
const decoder = KiloRipgrepStream.decoder()
const lines: string[] = []
const first = KiloRipgrepStream.drain(decoder, "", bytes.subarray(0, 5), (line) => lines.push(line))
const rest = KiloRipgrepStream.drain(decoder, first, bytes.subarray(5), (line) => lines.push(line)) + decoder.end()
if (rest) lines.push(rest)
expect(lines).toEqual([`src/${icon}.ts`, "next.ts"])
})
})
@@ -0,0 +1,172 @@
// Tests the kilocode-specific patch module guarantees:
// - Files retain their original encoding after an update (UTF-8 BOM, UTF-16,
// legacy single-byte, CJK).
// - Plain UTF-8 files do not gain a spurious BOM.
// - Moved files keep the original encoding at the new path.
// These round-trip through Patch.applyPatch directly so we exercise the
// encoding + BOM integration in patch/index.ts without the tool stack.
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { tmpdir } from "os"
import iconv from "iconv-lite"
import { Patch } from "../../src/patch"
const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf])
const UTF16_LE_BOM = Buffer.from([0xff, 0xfe])
describe("Patch encoding preservation", () => {
let dir: string
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(tmpdir(), "kilo-patch-"))
})
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true })
})
test("preserves UTF-8 BOM through update", async () => {
const file = path.join(dir, "doc.txt")
await fs.writeFile(file, Buffer.concat([UTF8_BOM, Buffer.from("line 1\nline 2\n", "utf-8")]))
const patch = `*** Begin Patch
*** Update File: ${file}
@@
line 1
-line 2
+line 2 updated
*** End Patch`
await Patch.applyPatch(patch)
const bytes = await fs.readFile(file)
expect(bytes.subarray(0, 3).equals(UTF8_BOM)).toBe(true)
expect(bytes.subarray(3).toString("utf-8")).toBe("line 1\nline 2 updated\n")
})
test("does not introduce BOM for plain UTF-8 files", async () => {
const file = path.join(dir, "plain.txt")
await fs.writeFile(file, "line 1\nline 2\n", "utf-8")
const patch = `*** Begin Patch
*** Update File: ${file}
@@
line 1
-line 2
+line 2 updated
*** End Patch`
await Patch.applyPatch(patch)
const bytes = await fs.readFile(file)
expect(bytes[0]).not.toBe(0xef)
expect(bytes.toString("utf-8")).toBe("line 1\nline 2 updated\n")
})
test("preserves UTF-16 LE encoding through update", async () => {
const file = path.join(dir, "utf16.txt")
await fs.writeFile(file, Buffer.concat([UTF16_LE_BOM, iconv.encode("line 1\nline 2\n", "utf-16le")]))
const patch = `*** Begin Patch
*** Update File: ${file}
@@
line 1
-line 2
+line 2 updated
*** End Patch`
await Patch.applyPatch(patch)
const bytes = await fs.readFile(file)
expect(bytes.subarray(0, 2).equals(UTF16_LE_BOM)).toBe(true)
expect(iconv.decode(bytes.subarray(2), "utf-16le")).toBe("line 1\nline 2 updated\n")
})
test("preserves iso-8859-1 encoding through update", async () => {
const file = path.join(dir, "latin1.txt")
await fs.writeFile(file, iconv.encode("café\nñandú\n", "iso-8859-1"))
const patch = `*** Begin Patch
*** Update File: ${file}
@@
café
-ñandú
+águila
*** End Patch`
await Patch.applyPatch(patch)
const bytes = await fs.readFile(file)
expect(iconv.decode(bytes, "iso-8859-1")).toBe("café\náguila\n")
// á and ñ are two bytes in UTF-8, one byte in ISO-8859-1. If the file had
// been silently re-encoded as UTF-8 the byte length would differ.
expect(bytes.length).toBe("café\náguila\n".length)
})
test("preserves Shift_JIS encoding through update", async () => {
const file = path.join(dir, "jp.txt")
// jschardet needs enough characteristic bytes to identify Shift_JIS. A
// single 19-byte phrase looks like windows-1252, so the sample is padded
// to match the body of Japanese text the tool tests already rely on.
const sample = "こんにちは、世界!日本語のテストです。"
await fs.writeFile(file, iconv.encode(`line1\n${sample}\nline3\n`, "Shift_JIS"))
const patch = `*** Begin Patch
*** Update File: ${file}
@@
line1
-${sample}
+さようなら、世界!
line3
*** End Patch`
await Patch.applyPatch(patch)
const bytes = await fs.readFile(file)
expect(iconv.decode(bytes, "Shift_JIS")).toBe("line1\nさようなら、世界!\nline3\n")
const utf8Rendered = Buffer.from("line1\nさようなら、世界!\nline3\n", "utf-8")
expect(bytes.equals(utf8Rendered)).toBe(false)
})
test("preserves UTF-8 BOM when file is moved", async () => {
const from = path.join(dir, "old.txt")
const to = path.join(dir, "new.txt")
await fs.writeFile(from, Buffer.concat([UTF8_BOM, Buffer.from("original\n", "utf-8")]))
const patch = `*** Begin Patch
*** Update File: ${from}
*** Move to: ${to}
@@
-original
+updated
*** End Patch`
await Patch.applyPatch(patch)
const moved = await fs.readFile(to)
expect(moved.subarray(0, 3).equals(UTF8_BOM)).toBe(true)
expect(moved.subarray(3).toString("utf-8")).toBe("updated\n")
const oldExists = await fs
.access(from)
.then(() => true)
.catch(() => false)
expect(oldExists).toBe(false)
})
test("new files added via patch are written as plain UTF-8", async () => {
const file = path.join(dir, "new.txt")
const patch = `*** Begin Patch
*** Add File: ${file}
+hello world
*** End Patch`
await Patch.applyPatch(patch)
const bytes = await fs.readFile(file)
expect(bytes[0]).not.toBe(0xef)
expect(bytes.toString("utf-8")).toBe("hello world")
})
})
@@ -0,0 +1,28 @@
import { test, expect } from "bun:test"
import { Snapshot } from "../../src/snapshot"
test("SummaryFileDiff does not contain the `patch` field", () => {
const keys = Object.keys(Snapshot.SummaryFileDiff.fields)
expect(keys).not.toContain("patch")
expect(keys.sort()).toEqual(["additions", "deletions", "file", "status"])
})
test("SummaryFileDiff parse strips `patch` when present on input", () => {
const full = {
file: "a.txt",
patch: "@@ -1 +1 @@\n-old\n+new\n",
additions: 1,
deletions: 1,
status: "modified" as const,
}
const parsed = Snapshot.SummaryFileDiff.zod.parse(full)
expect(parsed).not.toHaveProperty("patch")
expect(parsed).toEqual({ file: "a.txt", additions: 1, deletions: 1, status: "modified" })
})
test("SummaryFileDiff differs from FileDiff by exactly `patch`", () => {
const full = new Set(Object.keys(Snapshot.FileDiff.fields))
const summary = new Set(Object.keys(Snapshot.SummaryFileDiff.fields))
expect([...full].filter((k) => !summary.has(k))).toEqual(["patch"])
expect([...summary].filter((k) => !full.has(k))).toEqual([])
})
+394 -10
View File
@@ -1,11 +1,12 @@
import { describe, expect, test, beforeEach } from "bun:test"
import { beforeEach, describe, expect, test } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../fixture/fixture"
import { LSPClient } from "../../src/lsp"
import { LSPServer } from "../../src/lsp"
import { Instance } from "../../src/project/instance"
import { Log } from "../../src/util"
// Minimal fake LSP server that speaks JSON-RPC over stdio
function spawnFakeServer() {
const { spawn } = require("child_process")
const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js")
@@ -39,10 +40,8 @@ describe("LSPClient interop", () => {
method: "workspace/workspaceFolders",
})
await new Promise((r) => setTimeout(r, 100))
await new Promise((resolve) => setTimeout(resolve, 100))
expect(client.connection).toBeDefined()
await client.shutdown()
})
@@ -64,10 +63,8 @@ describe("LSPClient interop", () => {
method: "client/registerCapability",
})
await new Promise((r) => setTimeout(r, 100))
await new Promise((resolve) => setTimeout(resolve, 100))
expect(client.connection).toBeDefined()
await client.shutdown()
})
@@ -89,10 +86,397 @@ describe("LSPClient interop", () => {
method: "client/unregisterCapability",
})
await new Promise((r) => setTimeout(r, 100))
await new Promise((resolve) => setTimeout(resolve, 100))
expect(client.connection).toBeDefined()
await client.shutdown()
})
test("initialize does not overclaim unsupported diagnostics capabilities", async () => {
const handle = spawnFakeServer() as any
const client = await Instance.provide({
directory: process.cwd(),
fn: () =>
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
}),
})
const params = await client.connection.sendRequest<any>("test/get-initialize-params", {})
expect(params.capabilities.workspace.diagnostics.refreshSupport).toBe(false)
expect(params.capabilities.textDocument.publishDiagnostics.versionSupport).toBe(false)
await client.shutdown()
})
test("workspace/configuration returns one result per requested item", async () => {
const handle = spawnFakeServer() as any
const initialization = {
alpha: {
beta: 1,
},
gamma: true,
}
const client = await Instance.provide({
directory: process.cwd(),
fn: () =>
LSPClient.create({
serverID: "fake",
server: {
...(handle as unknown as LSPServer.Handle),
initialization,
},
root: process.cwd(),
directory: process.cwd(),
}),
})
const response = await client.connection.sendRequest<any[]>("test/request-configuration", {
items: [{ section: "alpha" }, { section: "alpha.beta" }, { section: "missing" }, {}],
})
expect(response).toEqual([{ beta: 1 }, 1, null, initialization])
await client.shutdown()
})
test("sends ranged didChange for incremental sync servers", async () => {
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.ts")
await Bun.write(file, "first\n")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
})
await client.notify.open({ path: file })
await Bun.write(file, "second\nthird\n")
await client.notify.open({ path: file })
const change = await client.connection.sendRequest<{
textDocument: { version: number }
contentChanges: {
range?: { start: { line: number; character: number }; end: { line: number; character: number } }
text: string
}[]
}>("test/get-last-change", {})
expect(change.textDocument.version).toBe(1)
expect(change.contentChanges).toEqual([
{
range: {
start: { line: 0, character: 0 },
end: { line: 1, character: 0 },
},
text: "second\nthird\n",
},
])
await client.shutdown()
},
})
})
test("document mode falls back to push diagnostics", async () => {
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.ts")
await Bun.write(file, "const x = 1\n")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
})
const version = await client.notify.open({ path: file })
const wait = client.waitForDiagnostics({ path: file, version, mode: "document" })
await client.connection.sendNotification("test/publish-diagnostics", {
uri: pathToFileURL(file).href,
version,
diagnostics: [
{
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 5 },
},
message: "push diagnostic",
severity: 1,
},
],
})
await wait
const diagnostics = client.diagnostics.get(file) ?? []
expect(diagnostics).toHaveLength(1)
expect(diagnostics[0]?.message).toBe("push diagnostic")
const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {})
expect(count).toBe(0)
await client.shutdown()
},
})
})
test("document mode accepts matching push diagnostics published before waiting", async () => {
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.ts")
await Bun.write(file, "const x = 1\n")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
})
const version = await client.notify.open({ path: file })
await client.connection.sendNotification("test/publish-diagnostics", {
uri: pathToFileURL(file).href,
version,
diagnostics: [
{
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 5 },
},
message: "push diagnostic",
severity: 1,
},
],
})
for (let i = 0; i < 20 && (client.diagnostics.get(file)?.length ?? 0) === 0; i++) {
await new Promise((resolve) => setTimeout(resolve, 25))
}
expect(client.diagnostics.get(file)?.[0]?.message).toBe("push diagnostic")
const started = Date.now()
await client.waitForDiagnostics({ path: file, version, mode: "document" })
expect(Date.now() - started).toBeLessThan(1_000)
await client.shutdown()
},
})
})
test("document mode waits for pull diagnostics", async () => {
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.cs")
await Bun.write(file, "class C {}\n")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
})
await client.connection.sendRequest("test/configure-pull-diagnostics", {
registerOn: "didOpen",
registrations: [{ identifier: "DocumentCompilerSemantic" }],
documentDiagnosticsByIdentifier: {
DocumentCompilerSemantic: [
{
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 5 },
},
message: "pull diagnostic",
severity: 1,
},
],
},
})
const version = await client.notify.open({ path: file })
await client.waitForDiagnostics({ path: file, version, mode: "document" })
const diagnostics = client.diagnostics.get(file) ?? []
expect(diagnostics).toHaveLength(1)
expect(diagnostics[0]?.message).toBe("pull diagnostic")
const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {})
expect(count).toBeGreaterThan(0)
await client.shutdown()
},
})
})
test("document mode does not wait for the slowest pull identifier after current-file diagnostics arrive", async () => {
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.cs")
await Bun.write(file, "class C {}\n")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
})
await client.connection.sendRequest("test/configure-pull-diagnostics", {
registrations: [{ identifier: "fast" }, { identifier: "slow" }],
documentDiagnosticsByIdentifier: {
fast: [
{
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 5 },
},
message: "fast diagnostic",
severity: 1,
},
],
slow: [],
},
documentDelayMsByIdentifier: {
slow: 2_500,
},
})
const version = await client.notify.open({ path: file })
await client.connection.sendRequest("test/register-configured-pull-diagnostics", {})
await new Promise((resolve) => setTimeout(resolve, 100))
const started = Date.now()
await client.waitForDiagnostics({ path: file, version, mode: "document" })
expect(Date.now() - started).toBeLessThan(1_000)
expect(client.diagnostics.get(file)?.[0]?.message).toBe("fast diagnostic")
expect(await client.connection.sendRequest("test/get-diagnostic-request-count", {})).toBeGreaterThan(1)
await client.shutdown()
},
})
})
test("full mode includes workspace pull diagnostics", async () => {
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.cs")
const related = path.join(tmp.path, "other.cs")
await Bun.write(file, "class C {}\n")
await Bun.write(related, "class D {}\n")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
})
await client.connection.sendRequest("test/configure-pull-diagnostics", {
registerOn: "didOpen",
registrations: [
{ identifier: "DocumentCompilerSemantic" },
{ identifier: "WorkspaceDocumentsAndProject", workspaceDiagnostics: true },
],
documentDiagnosticsByIdentifier: {
DocumentCompilerSemantic: [
{
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 5 },
},
message: "current file",
severity: 1,
},
],
},
workspaceDiagnosticsByIdentifier: {
WorkspaceDocumentsAndProject: [
{
uri: pathToFileURL(related).href,
items: [
{
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 5 },
},
message: "workspace file",
severity: 1,
},
],
},
],
},
})
const version = await client.notify.open({ path: file })
await client.waitForDiagnostics({ path: file, version, mode: "full" })
expect(client.diagnostics.get(file)?.[0]?.message).toBe("current file")
expect(client.diagnostics.get(related)?.[0]?.message).toBe("workspace file")
await client.shutdown()
},
})
})
test("full mode treats an empty workspace pull response as handled", async () => {
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.cs")
await Bun.write(file, "class C {}\n")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
})
await client.connection.sendRequest("test/configure-pull-diagnostics", {
registerOn: "didOpen",
registrations: [{ identifier: "WorkspaceDocumentsAndProject", workspaceDiagnostics: true }],
workspaceDiagnosticsByIdentifier: {
WorkspaceDocumentsAndProject: [],
},
})
const version = await client.notify.open({ path: file })
const started = Date.now()
await client.waitForDiagnostics({ path: file, version, mode: "full" })
expect(Date.now() - started).toBeLessThan(1_000)
await client.shutdown()
},
})
})
})
+37
View File
@@ -1,7 +1,18 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect, test } from "bun:test"
import { Npm } from "../src/npm"
import { tmpdir } from "./fixture/fixture"
const win = process.platform === "win32"
const writePackage = (dir: string, pkg: Record<string, unknown>) =>
Bun.write(
path.join(dir, "package.json"),
JSON.stringify({
version: "1.0.0",
...pkg,
}),
)
describe("Npm.sanitize", () => {
test("keeps normal scoped package specs unchanged", () => {
@@ -16,3 +27,29 @@ describe("Npm.sanitize", () => {
expect(Npm.sanitize(spec)).toBe(expected)
})
})
describe("Npm.install", () => {
test("respects omit from project .npmrc", async () => {
await using tmp = await tmpdir()
await writePackage(tmp.path, {
name: "fixture",
dependencies: {
"prod-pkg": "file:./prod-pkg",
},
devDependencies: {
"dev-pkg": "file:./dev-pkg",
},
})
await Bun.write(path.join(tmp.path, ".npmrc"), "omit=dev\n")
await fs.mkdir(path.join(tmp.path, "prod-pkg"))
await fs.mkdir(path.join(tmp.path, "dev-pkg"))
await writePackage(path.join(tmp.path, "prod-pkg"), { name: "prod-pkg" })
await writePackage(path.join(tmp.path, "dev-pkg"), { name: "dev-pkg" })
await Npm.install(tmp.path)
await expect(fs.stat(path.join(tmp.path, "node_modules", "prod-pkg"))).resolves.toBeDefined()
await expect(fs.stat(path.join(tmp.path, "node_modules", "dev-pkg"))).rejects.toThrow()
})
})
+63 -3
View File
@@ -142,6 +142,67 @@ test("fromConfig - does not expand tilde in middle of path", () => {
expect(result).toEqual([{ permission: "external_directory", pattern: "/some/~/path", action: "allow" }])
})
// Top-level wildcard-vs-specific precedence semantics.
//
// fromConfig sorts top-level keys so wildcard permissions (containing "*")
// come before specific permissions. Combined with `findLast` in evaluate(),
// this gives the intuitive semantic "specific tool rules override the `*`
// fallback", regardless of the order the user wrote the keys in their JSON.
//
// Sub-pattern order inside a single permission key (e.g. `bash: { "*": "allow", "rm": "deny" }`)
// still depends on insertion order — only top-level keys are sorted.
test("fromConfig - specific key beats wildcard regardless of JSON key order", () => {
const wildcardFirst = Permission.fromConfig({ "*": "deny", bash: "allow" })
const specificFirst = Permission.fromConfig({ bash: "allow", "*": "deny" })
// Both orderings produce the same ruleset
expect(wildcardFirst).toEqual(specificFirst)
// And both evaluate bash → allow (bash rule wins over * fallback)
expect(Permission.evaluate("bash", "ls", wildcardFirst).action).toBe("allow")
expect(Permission.evaluate("bash", "ls", specificFirst).action).toBe("allow")
})
test("fromConfig - wildcard acts as fallback for permissions with no specific rule", () => {
const ruleset = Permission.fromConfig({ bash: "allow", "*": "ask" })
expect(Permission.evaluate("edit", "foo.ts", ruleset).action).toBe("ask")
expect(Permission.evaluate("bash", "ls", ruleset).action).toBe("allow")
})
test("fromConfig - top-level ordering: wildcards first, specifics after", () => {
const ruleset = Permission.fromConfig({
bash: "allow",
"*": "ask",
edit: "deny",
"mcp_*": "allow",
})
// wildcards (* and mcp_*) come before specifics (bash, edit)
const permissions = ruleset.map((r) => r.permission)
expect(permissions.slice(0, 2).sort()).toEqual(["*", "mcp_*"])
expect(permissions.slice(2)).toEqual(["bash", "edit"])
})
test("fromConfig - sub-pattern insertion order inside a tool key is preserved (only top-level sorts)", () => {
// Sub-patterns within a single tool key use the documented "`*` first,
// specific patterns after" convention (findLast picks specifics). The
// top-level sort must not touch sub-pattern ordering.
const ruleset = Permission.fromConfig({ bash: { "*": "deny", "git *": "allow" } })
expect(ruleset.map((r) => r.pattern)).toEqual(["*", "git *"])
// * fallback for unknown commands
expect(Permission.evaluate("bash", "rm foo", ruleset).action).toBe("deny")
// specific pattern wins for git commands (it's last, findLast picks it)
expect(Permission.evaluate("bash", "git status", ruleset).action).toBe("allow")
})
test("fromConfig - canonical documented example unchanged", () => {
// Regression guard for the example in docs/permissions.mdx
const ruleset = Permission.fromConfig({ "*": "ask", bash: "allow", edit: "deny" })
expect(Permission.evaluate("bash", "ls", ruleset).action).toBe("allow")
expect(Permission.evaluate("edit", "foo.ts", ruleset).action).toBe("deny")
expect(Permission.evaluate("read", "foo.ts", ruleset).action).toBe("ask")
})
test("fromConfig - expands exact tilde to home directory", () => {
const result = Permission.fromConfig({ external_directory: { "~": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }])
@@ -436,9 +497,9 @@ test("disabled - disables tool when denied", () => {
expect(result.has("read")).toBe(false)
})
test("disabled - disables edit/write/apply_patch/multiedit when edit denied", () => {
test("disabled - disables edit/write/apply_patch when edit denied", () => {
const result = Permission.disabled(
["edit", "write", "apply_patch", "multiedit", "bash"],
["edit", "write", "apply_patch", "bash"],
[
{ permission: "*", pattern: "*", action: "allow" },
{ permission: "edit", pattern: "*", action: "deny" },
@@ -447,7 +508,6 @@ test("disabled - disables edit/write/apply_patch/multiedit when edit denied", ()
expect(result.has("edit")).toBe(true)
expect(result.has("write")).toBe(true)
expect(result.has("apply_patch")).toBe(true)
expect(result.has("multiedit")).toBe(true)
expect(result.has("bash")).toBe(false)
})
+130 -1
View File
@@ -278,6 +278,31 @@ describe("Project.discover", () => {
expect(updated).toBeDefined()
expect(updated!.icon).toBeUndefined()
})
test("should not discover favicon when override is set", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
await run((svc) =>
svc.update({
projectID: project.id,
icon: { override: "data:image/png;base64,override" },
}),
)
const updatedProject = await run((svc) => svc.get(project.id))
if (!updatedProject) throw new Error("Project not found")
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
await Bun.write(path.join(tmp.path, "favicon.png"), pngData)
await run((svc) => svc.discover(updatedProject))
const updated = Project.get(project.id)
expect(updated).toBeDefined()
expect(updated!.icon?.override).toBe("data:image/png;base64,override")
expect(updated!.icon?.url).toBeUndefined()
})
})
describe("Project.update", () => {
@@ -332,6 +357,23 @@ describe("Project.update", () => {
expect(fromDb?.icon?.color).toBe("#ff0000")
})
test("should update icon override", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const updated = await run((svc) =>
svc.update({
projectID: project.id,
icon: { override: "data:image/png;base64,abc123" },
}),
)
expect(updated.icon?.override).toBe("data:image/png;base64,abc123")
const fromDb = Project.get(project.id)
expect(fromDb?.icon?.override).toBe("data:image/png;base64,abc123")
})
test("should update commands", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
@@ -389,13 +431,14 @@ describe("Project.update", () => {
svc.update({
projectID: project.id,
name: "Multi Update",
icon: { url: "https://example.com/favicon.ico", color: "#00ff00" },
icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" },
commands: { start: "make start" },
}),
)
expect(updated.name).toBe("Multi Update")
expect(updated.icon?.url).toBe("https://example.com/favicon.ico")
expect(updated.icon?.override).toBe("data:image/png;base64,abc123")
expect(updated.icon?.color).toBe("#00ff00")
expect(updated.commands?.start).toBe("make start")
})
@@ -472,3 +515,89 @@ describe("Project.addSandbox and Project.removeSandbox", () => {
expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true)
})
})
describe("Project.fromDirectory with bare repos", () => {
test("worktree from bare repo should cache in bare repo, not parent", async () => {
await using tmp = await tmpdir({ git: true })
const parentDir = path.dirname(tmp.path)
const barePath = path.join(parentDir, `bare-${Date.now()}.git`)
const worktreePath = path.join(parentDir, `worktree-${Date.now()}`)
try {
await $`git clone --bare ${tmp.path} ${barePath}`.quiet()
await $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet()
const { project } = await run((svc) => svc.fromDirectory(worktreePath))
expect(project.id).not.toBe(ProjectID.global)
expect(project.worktree).toBe(barePath)
const correctCache = path.join(barePath, "kilo") // kilocode_change
const wrongCache = path.join(parentDir, ".git", "kilo") // kilocode_change
expect(await Bun.file(correctCache).exists()).toBe(true)
expect(await Bun.file(wrongCache).exists()).toBe(false)
} finally {
await $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow()
}
})
test("different bare repos under same parent should not share project ID", async () => {
await using tmp1 = await tmpdir({ git: true })
await using tmp2 = await tmpdir({ git: true })
const parentDir = path.dirname(tmp1.path)
const bareA = path.join(parentDir, `bare-a-${Date.now()}.git`)
const bareB = path.join(parentDir, `bare-b-${Date.now()}.git`)
const worktreeA = path.join(parentDir, `wt-a-${Date.now()}`)
const worktreeB = path.join(parentDir, `wt-b-${Date.now()}`)
try {
await $`git clone --bare ${tmp1.path} ${bareA}`.quiet()
await $`git clone --bare ${tmp2.path} ${bareB}`.quiet()
await $`git worktree add ${worktreeA} HEAD`.cwd(bareA).quiet()
await $`git worktree add ${worktreeB} HEAD`.cwd(bareB).quiet()
const { project: projA } = await run((svc) => svc.fromDirectory(worktreeA))
const { project: projB } = await run((svc) => svc.fromDirectory(worktreeB))
expect(projA.id).not.toBe(projB.id)
// kilocode_change start
const cacheA = path.join(bareA, "kilo")
const cacheB = path.join(bareB, "kilo")
const wrongCache = path.join(parentDir, ".git", "kilo")
// kilocode_change end
expect(await Bun.file(cacheA).exists()).toBe(true)
expect(await Bun.file(cacheB).exists()).toBe(true)
expect(await Bun.file(wrongCache).exists()).toBe(false)
} finally {
await $`rm -rf ${bareA} ${bareB} ${worktreeA} ${worktreeB}`.quiet().nothrow()
}
})
test("bare repo without .git suffix is still detected via core.bare", async () => {
await using tmp = await tmpdir({ git: true })
const parentDir = path.dirname(tmp.path)
const barePath = path.join(parentDir, `bare-no-suffix-${Date.now()}`)
const worktreePath = path.join(parentDir, `worktree-${Date.now()}`)
try {
await $`git clone --bare ${tmp.path} ${barePath}`.quiet()
await $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet()
const { project } = await run((svc) => svc.fromDirectory(worktreePath))
expect(project.id).not.toBe(ProjectID.global)
expect(project.worktree).toBe(barePath)
const correctCache = path.join(barePath, "kilo") // kilocode_change
expect(await Bun.file(correctCache).exists()).toBe(true)
} finally {
await $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow()
}
})
})
@@ -2114,7 +2114,24 @@ describe("ProviderTransform.variants", () => {
expect(result.low).toEqual({ reasoningEffort: "low" })
})
test("mistral returns empty object", () => {
test("mistral with reasoning returns variants", () => {
const model = createMockModel({
id: "mistral/mistral-small-latest",
providerID: "mistral",
api: {
id: "mistral-small-latest",
url: "https://api.mistral.com",
npm: "@ai-sdk/mistral",
},
capabilities: { reasoning: true },
})
const result = ProviderTransform.variants(model)
expect(result).toEqual({
high: { reasoningEffort: "high" },
})
})
test("mistral without reasoning returns empty object", () => {
const model = createMockModel({
id: "mistral/mistral-large",
providerID: "mistral",
@@ -2123,6 +2140,22 @@ describe("ProviderTransform.variants", () => {
url: "https://api.mistral.com",
npm: "@ai-sdk/mistral",
},
capabilities: { reasoning: false },
})
const result = ProviderTransform.variants(model)
expect(result).toEqual({})
})
test("mistral large with reasoning returns empty object (only small supports reasoning)", () => {
const model = createMockModel({
id: "mistral/mistral-large",
providerID: "mistral",
api: {
id: "mistral-large-latest",
url: "https://api.mistral.com",
npm: "@ai-sdk/mistral",
},
capabilities: { reasoning: true },
})
const result = ProviderTransform.variants(model)
expect(result).toEqual({})
+725 -75
View File
@@ -143,6 +143,45 @@ async function assistant(sessionID: SessionID, parentID: MessageID, root: string
return msg
}
async function summaryAssistant(sessionID: SessionID, parentID: MessageID, root: string, text: string) {
const msg: MessageV2.Assistant = {
id: MessageID.ascending(),
role: "assistant",
sessionID,
mode: "compaction",
agent: "compaction",
path: { cwd: root, root },
cost: 0,
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: ref.modelID,
providerID: ref.providerID,
parentID,
summary: true,
time: { created: Date.now() },
finish: "end_turn",
}
await svc.updateMessage(msg)
await svc.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID,
type: "text",
text,
})
return msg
}
async function lastCompactionPart(sessionID: SessionID) {
return (await svc.messages({ sessionID }))
.at(-2)
?.parts.find((item): item is MessageV2.CompactionPart => item.type === "compaction")
}
function fake(
input: Parameters<SessionProcessorModule.SessionProcessor.Interface["create"]>[0],
result: "continue" | "compact",
@@ -167,7 +206,19 @@ function layer(result: "continue" | "compact") {
)
}
function runtime(result: "continue" | "compact", plugin = Plugin.defaultLayer, provider = ProviderTest.fake()) {
function cfg(compaction?: Config.Info["compaction"]) {
const base = Config.Info.zod.parse({})
return Layer.mock(Config.Service)({
get: () => Effect.succeed({ ...base, compaction }),
})
}
function runtime(
result: "continue" | "compact",
plugin = Plugin.defaultLayer,
provider = ProviderTest.fake(),
config = Config.defaultLayer,
) {
const bus = Bus.layer
return ManagedRuntime.make(
Layer.mergeAll(SessionCompaction.layer, bus).pipe(
@@ -177,7 +228,7 @@ function runtime(result: "continue" | "compact", plugin = Plugin.defaultLayer, p
Layer.provide(Agent.defaultLayer),
Layer.provide(plugin),
Layer.provide(bus),
Layer.provide(Config.defaultLayer),
Layer.provide(config),
),
)
}
@@ -222,7 +273,7 @@ function llm() {
}
}
function liveRuntime(layer: Layer.Layer<LLM.Service>, provider = ProviderTest.fake()) {
function liveRuntime(layer: Layer.Layer<LLM.Service>, provider = ProviderTest.fake(), config = Config.defaultLayer) {
const bus = Bus.layer
const status = SessionStatus.layer.pipe(Layer.provide(bus))
const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary))
@@ -237,11 +288,66 @@ function liveRuntime(layer: Layer.Layer<LLM.Service>, provider = ProviderTest.fa
Layer.provide(Plugin.defaultLayer),
Layer.provide(status),
Layer.provide(bus),
Layer.provide(Config.defaultLayer),
Layer.provide(config),
),
)
}
function reply(
text: string,
capture?: (input: LLM.StreamInput) => void,
): (input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown> {
return (input) => {
capture?.(input)
return Stream.make(
{ type: "start" } satisfies LLM.Event,
{ type: "text-start", id: "txt-0" } satisfies LLM.Event,
{ type: "text-delta", id: "txt-0", delta: text, text } as LLM.Event,
{ type: "text-end", id: "txt-0" } satisfies LLM.Event,
{
type: "finish-step",
finishReason: "stop",
rawFinishReason: "stop",
response: { id: "res", modelId: "test-model", timestamp: new Date() },
providerMetadata: undefined,
usage: {
inputTokens: 1,
outputTokens: 1,
totalTokens: 2,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
} satisfies LLM.Event,
{
type: "finish",
finishReason: "stop",
rawFinishReason: "stop",
totalUsage: {
inputTokens: 1,
outputTokens: 1,
totalTokens: 2,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
} satisfies LLM.Event,
)
}
}
function wait(ms = 50) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
@@ -498,65 +604,13 @@ describe("session.compaction.create", () => {
describe("session.compaction.prune", () => {
it.live(
"compacts old completed tool output",
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const ssn = yield* SessionNs.Service
const info = yield* ssn.create({})
const a = yield* ssn.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
agent: "build",
model: ref,
time: { created: Date.now() },
})
yield* ssn.updatePart({
id: PartID.ascending(),
messageID: a.id,
sessionID: info.id,
type: "text",
text: "first",
})
const b: MessageV2.Assistant = {
id: MessageID.ascending(),
role: "assistant",
sessionID: info.id,
mode: "build",
agent: "build",
path: { cwd: dir, root: dir },
cost: 0,
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: ref.modelID,
providerID: ref.providerID,
parentID: a.id,
time: { created: Date.now() },
finish: "end_turn",
}
yield* ssn.updateMessage(b)
yield* ssn.updatePart({
id: PartID.ascending(),
messageID: b.id,
sessionID: info.id,
type: "tool",
callID: crypto.randomUUID(),
tool: "bash",
state: {
status: "completed",
input: {},
output: "x".repeat(200_000),
title: "done",
metadata: {},
time: { start: Date.now(), end: Date.now() },
},
})
for (const text of ["second", "third"]) {
const msg = yield* ssn.updateMessage({
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const ssn = yield* SessionNs.Service
const info = yield* ssn.create({})
const a = yield* ssn.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
@@ -566,23 +620,82 @@ describe("session.compaction.prune", () => {
})
yield* ssn.updatePart({
id: PartID.ascending(),
messageID: msg.id,
messageID: a.id,
sessionID: info.id,
type: "text",
text,
text: "first",
})
}
const b: MessageV2.Assistant = {
id: MessageID.ascending(),
role: "assistant",
sessionID: info.id,
mode: "build",
agent: "build",
path: { cwd: dir, root: dir },
cost: 0,
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: ref.modelID,
providerID: ref.providerID,
parentID: a.id,
time: { created: Date.now() },
finish: "end_turn",
}
yield* ssn.updateMessage(b)
yield* ssn.updatePart({
id: PartID.ascending(),
messageID: b.id,
sessionID: info.id,
type: "tool",
callID: crypto.randomUUID(),
tool: "bash",
state: {
status: "completed",
input: {},
output: "x".repeat(200_000),
title: "done",
metadata: {},
time: { start: Date.now(), end: Date.now() },
},
})
for (const text of ["second", "third"]) {
const msg = yield* ssn.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
agent: "build",
model: ref,
time: { created: Date.now() },
})
yield* ssn.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID: info.id,
type: "text",
text,
})
}
yield* compact.prune({ sessionID: info.id })
yield* compact.prune({ sessionID: info.id })
const msgs = yield* ssn.messages({ sessionID: info.id })
const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
expect(part?.type).toBe("tool")
expect(part?.state.status).toBe("completed")
if (part?.type === "tool" && part.state.status === "completed") {
expect(part.state.time.compacted).toBeNumber()
}
}),
const msgs = yield* ssn.messages({ sessionID: info.id })
const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
expect(part?.type).toBe("tool")
expect(part?.state.status).toBe("completed")
if (part?.type === "tool" && part.state.status === "completed") {
expect(part.state.time.compacted).toBeNumber()
}
}),
{
config: {
compaction: { prune: true },
},
},
),
)
@@ -836,6 +949,273 @@ describe("session.compaction.process", () => {
})
})
test("persists tail_start_id for retained recent turns", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
await user(session.id, "first")
const keep = await user(session.id, "second")
await user(session.id, "third")
await SessionCompaction.create({
sessionID: session.id,
agent: "build",
model: ref,
auto: false,
})
const rt = runtime(
"continue",
Plugin.defaultLayer,
wide(),
cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }),
)
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
const part = await lastCompactionPart(session.id)
expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBe(keep.id)
} finally {
await rt.dispose()
}
},
})
})
test("shrinks retained tail to fit preserve token budget", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
await user(session.id, "first")
await user(session.id, "x".repeat(2_000))
const keep = await user(session.id, "tiny")
await SessionCompaction.create({
sessionID: session.id,
agent: "build",
model: ref,
auto: false,
})
const rt = runtime("continue", Plugin.defaultLayer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 100 }))
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
const part = await lastCompactionPart(session.id)
expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBe(keep.id)
} finally {
await rt.dispose()
}
},
})
})
test("falls back to full summary when even one recent turn exceeds preserve token budget", async () => {
await using tmp = await tmpdir({ git: true })
const stub = llm()
let captured = ""
stub.push(
reply("summary", (input) => {
captured = JSON.stringify(input.messages)
}),
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
await user(session.id, "first")
await user(session.id, "y".repeat(2_000))
await SessionCompaction.create({
sessionID: session.id,
agent: "build",
model: ref,
auto: false,
})
const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 1, preserve_recent_tokens: 20 }))
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
const part = await lastCompactionPart(session.id)
expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBeUndefined()
expect(captured).toContain("yyyy")
} finally {
await rt.dispose()
}
},
})
})
test("falls back to full summary when retained tail media exceeds preserve token budget", async () => {
await using tmp = await tmpdir({ git: true })
const stub = llm()
let captured = ""
stub.push(
reply("summary", (input) => {
captured = JSON.stringify(input.messages)
}),
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
await user(session.id, "older")
const recent = await user(session.id, "recent image turn")
await svc.updatePart({
id: PartID.ascending(),
messageID: recent.id,
sessionID: session.id,
type: "file",
mime: "image/png",
filename: "big.png",
url: `data:image/png;base64,${"a".repeat(4_000)}`,
})
await SessionCompaction.create({
sessionID: session.id,
agent: "build",
model: ref,
auto: false,
})
const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 1, preserve_recent_tokens: 100 }))
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
const part = await lastCompactionPart(session.id)
expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBeUndefined()
expect(captured).toContain("recent image turn")
expect(captured).toContain("Attached image/png: big.png")
} finally {
await rt.dispose()
}
},
})
})
test("retains a split turn suffix when a later message fits the preserve token budget", async () => {
await using tmp = await tmpdir({ git: true })
const stub = llm()
let captured = ""
stub.push(
reply("summary", (input) => {
captured = JSON.stringify(input.messages)
}),
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
await user(session.id, "older")
const recent = await user(session.id, "recent turn")
const large = await assistant(session.id, recent.id, tmp.path)
await svc.updatePart({
id: PartID.ascending(),
messageID: large.id,
sessionID: session.id,
type: "text",
text: "z".repeat(2_000),
})
const keep = await assistant(session.id, recent.id, tmp.path)
await svc.updatePart({
id: PartID.ascending(),
messageID: keep.id,
sessionID: session.id,
type: "text",
text: "keep tail",
})
await SessionCompaction.create({
sessionID: session.id,
agent: "build",
model: ref,
auto: false,
})
const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 1, preserve_recent_tokens: 100 }))
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
const part = await lastCompactionPart(session.id)
expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBe(keep.id)
expect(captured).toContain("zzzz")
expect(captured).not.toContain("keep tail")
const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id))
expect(filtered[0]?.info.id).toBe(keep.id)
expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id)
} finally {
await rt.dispose()
}
},
})
})
test("allows plugins to disable synthetic continue prompt", async () => {
await using tmp = await tmpdir()
await Instance.provide({
@@ -1199,6 +1579,276 @@ describe("session.compaction.process", () => {
},
})
})
test("summarizes only the head while keeping recent tail out of summary input", async () => {
const stub = llm()
let captured = ""
stub.push(
reply("summary", (input) => {
captured = JSON.stringify(input.messages)
}),
)
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
await user(session.id, "older context")
await user(session.id, "keep this turn")
await user(session.id, "and this one too")
await SessionCompaction.create({
sessionID: session.id,
agent: "build",
model: ref,
auto: false,
})
const rt = liveRuntime(stub.layer, wide())
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
expect(captured).toContain("older context")
expect(captured).not.toContain("keep this turn")
expect(captured).not.toContain("and this one too")
expect(captured).not.toContain("What did we do so far?")
} finally {
await rt.dispose()
}
},
})
})
test("anchors repeated compactions with the previous summary", async () => {
const stub = llm()
let captured = ""
stub.push(reply("summary one"))
stub.push(
reply("summary two", (input) => {
captured = JSON.stringify(input.messages)
}),
)
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
await user(session.id, "older context")
await user(session.id, "keep this turn")
await SessionCompaction.create({
sessionID: session.id,
agent: "build",
model: ref,
auto: false,
})
const rt = liveRuntime(stub.layer, wide())
try {
let msgs = await svc.messages({ sessionID: session.id })
let parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
await user(session.id, "latest turn")
await SessionCompaction.create({
sessionID: session.id,
agent: "build",
model: ref,
auto: false,
})
msgs = MessageV2.filterCompacted(MessageV2.stream(session.id))
parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
expect(captured).toContain("<previous-summary>")
expect(captured).toContain("summary one")
expect(captured.match(/summary one/g)?.length).toBe(1)
expect(captured).toContain("## Constraints & Preferences")
expect(captured).toContain("## Progress")
} finally {
await rt.dispose()
}
},
})
})
test("keeps recent pre-compaction turns across repeated compactions", async () => {
const stub = llm()
stub.push(reply("summary one"))
stub.push(reply("summary two"))
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const u1 = await user(session.id, "one")
const u2 = await user(session.id, "two")
const u3 = await user(session.id, "three")
await SessionCompaction.create({
sessionID: session.id,
agent: "build",
model: ref,
auto: false,
})
const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }))
try {
let msgs = await svc.messages({ sessionID: session.id })
let parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
const u4 = await user(session.id, "four")
await SessionCompaction.create({
sessionID: session.id,
agent: "build",
model: ref,
auto: false,
})
msgs = MessageV2.filterCompacted(MessageV2.stream(session.id))
parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id))
const ids = filtered.map((msg) => msg.info.id)
expect(ids).not.toContain(u1.id)
expect(ids).not.toContain(u2.id)
expect(ids).toContain(u3.id)
expect(ids).toContain(u4.id)
expect(filtered.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true)
expect(
filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")),
).toBe(true)
} finally {
await rt.dispose()
}
},
})
})
test("ignores previous summaries when sizing the retained tail", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
await user(session.id, "older")
const keep = await user(session.id, "keep this turn")
const keepReply = await assistant(session.id, keep.id, tmp.path)
await svc.updatePart({
id: PartID.ascending(),
messageID: keepReply.id,
sessionID: session.id,
type: "text",
text: "keep reply",
})
await SessionCompaction.create({
sessionID: session.id,
agent: "build",
model: ref,
auto: false,
})
const firstCompaction = (await svc.messages({ sessionID: session.id })).at(-1)?.info.id
expect(firstCompaction).toBeTruthy()
await summaryAssistant(session.id, firstCompaction!, tmp.path, "summary ".repeat(800))
const recent = await user(session.id, "recent turn")
const recentReply = await assistant(session.id, recent.id, tmp.path)
await svc.updatePart({
id: PartID.ascending(),
messageID: recentReply.id,
sessionID: session.id,
type: "text",
text: "recent reply",
})
await SessionCompaction.create({
sessionID: session.id,
agent: "build",
model: ref,
auto: false,
})
const rt = runtime("continue", Plugin.defaultLayer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 500 }))
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
const part = await lastCompactionPart(session.id)
expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBe(keep.id)
} finally {
await rt.dispose()
}
},
})
})
})
describe("util.token.estimate", () => {
@@ -585,6 +585,76 @@ describe("session.message-v2.toModelMessage", () => {
])
})
test("truncates tool output when requested", async () => {
const userID = "m-user"
const assistantID = "m-assistant"
const input: MessageV2.WithParts[] = [
{
info: userInfo(userID),
parts: [
{
...basePart(userID, "u1"),
type: "text",
text: "run tool",
},
] as MessageV2.Part[],
},
{
info: assistantInfo(assistantID, userID),
parts: [
{
...basePart(assistantID, "a1"),
type: "tool",
callID: "call-1",
tool: "bash",
state: {
status: "completed",
input: { cmd: "ls" },
output: "abcdefghij",
title: "Bash",
metadata: {},
time: { start: 0, end: 1 },
},
},
] as MessageV2.Part[],
},
]
expect(await MessageV2.toModelMessages(input, model, { toolOutputMaxChars: 4 })).toStrictEqual([
{
role: "user",
content: [{ type: "text", text: "run tool" }],
},
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call-1",
toolName: "bash",
input: { cmd: "ls" },
providerExecuted: undefined,
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: "bash",
output: {
type: "text",
value: "abcd\n[Tool output truncated for compaction: omitted 6 chars]",
},
},
],
},
])
})
test("converts assistant tool error into error-text tool result", async () => {
const userID = "m-user"
const assistantID = "m-assistant"
@@ -107,13 +107,14 @@ async function addAssistant(
return id
}
async function addCompactionPart(sessionID: SessionID, messageID: MessageID) {
async function addCompactionPart(sessionID: SessionID, messageID: MessageID, tailStartID?: MessageID) {
await svc.updatePart({
id: PartID.ascending(),
sessionID,
messageID,
type: "compaction",
auto: true,
tail_start_id: tailStartID,
} as any)
}
@@ -780,6 +781,203 @@ describe("MessageV2.filterCompacted", () => {
})
})
test("retains original tail when compaction stores tail_start_id", async () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await svc.create({})
const u1 = await addUser(session.id, "first")
const a1 = await addAssistant(session.id, u1, { finish: "end_turn" })
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: a1,
type: "text",
text: "first reply",
})
const u2 = await addUser(session.id, "second")
const a2 = await addAssistant(session.id, u2, { finish: "end_turn" })
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: a2,
type: "text",
text: "second reply",
})
const c1 = await addUser(session.id)
await addCompactionPart(session.id, c1, u2)
const s1 = await addAssistant(session.id, c1, { summary: true, finish: "end_turn" })
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: s1,
type: "text",
text: "summary",
})
const u3 = await addUser(session.id, "third")
const a3 = await addAssistant(session.id, u3, { finish: "end_turn" })
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: a3,
type: "text",
text: "third reply",
})
const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
expect(result.map((item) => item.info.id)).toEqual([u2, a2, c1, s1, u3, a3])
await svc.remove(session.id)
},
})
})
test("retains an assistant tail when compaction starts inside a turn", async () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await svc.create({})
const u1 = await addUser(session.id, "first")
const a1 = await addAssistant(session.id, u1, { finish: "end_turn" })
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: a1,
type: "text",
text: "first reply",
})
const u2 = await addUser(session.id, "second")
const a2 = await addAssistant(session.id, u2, { finish: "end_turn" })
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: a2,
type: "text",
text: "second reply",
})
const a3 = await addAssistant(session.id, u2, { finish: "end_turn" })
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: a3,
type: "text",
text: "tail reply",
})
const c1 = await addUser(session.id)
await addCompactionPart(session.id, c1, a3)
const s1 = await addAssistant(session.id, c1, { summary: true, finish: "end_turn" })
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: s1,
type: "text",
text: "summary",
})
const u3 = await addUser(session.id, "third")
const a4 = await addAssistant(session.id, u3, { finish: "end_turn" })
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: a4,
type: "text",
text: "third reply",
})
const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
expect(result.map((item) => item.info.id)).toEqual([a3, c1, s1, u3, a4])
await svc.remove(session.id)
},
})
})
test("prefers latest compaction boundary when repeated compactions exist", async () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await svc.create({})
const u1 = await addUser(session.id, "first")
const a1 = await addAssistant(session.id, u1, { finish: "end_turn" })
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: a1,
type: "text",
text: "first reply",
})
const u2 = await addUser(session.id, "second")
const a2 = await addAssistant(session.id, u2, { finish: "end_turn" })
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: a2,
type: "text",
text: "second reply",
})
const c1 = await addUser(session.id)
await addCompactionPart(session.id, c1, u2)
const s1 = await addAssistant(session.id, c1, { summary: true, finish: "end_turn" })
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: s1,
type: "text",
text: "summary one",
})
const u3 = await addUser(session.id, "third")
const a3 = await addAssistant(session.id, u3, { finish: "end_turn" })
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: a3,
type: "text",
text: "third reply",
})
const c2 = await addUser(session.id)
await addCompactionPart(session.id, c2, u3)
const s2 = await addAssistant(session.id, c2, { summary: true, finish: "end_turn" })
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: s2,
type: "text",
text: "summary two",
})
const u4 = await addUser(session.id, "fourth")
const a4 = await addAssistant(session.id, u4, { finish: "end_turn" })
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: a4,
type: "text",
text: "fourth reply",
})
const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
expect(result.map((item) => item.info.id)).toEqual([u3, a3, c2, s2, u4, a4])
await svc.remove(session.id)
},
})
})
test("works with array input", () => {
// filterCompacted accepts any Iterable, not just generators
const id = MessageID.ascending()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,7 @@ import { SessionID, MessageID } from "../../src/session/schema"
describe("structured-output.OutputFormat", () => {
test("parses text format", () => {
const result = MessageV2.Format.safeParse({ type: "text" })
const result = MessageV2.Format.zod.safeParse({ type: "text" })
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.type).toBe("text")
@@ -13,7 +13,7 @@ describe("structured-output.OutputFormat", () => {
})
test("parses json_schema format with defaults", () => {
const result = MessageV2.Format.safeParse({
const result = MessageV2.Format.zod.safeParse({
type: "json_schema",
schema: { type: "object", properties: { name: { type: "string" } } },
})
@@ -27,7 +27,7 @@ describe("structured-output.OutputFormat", () => {
})
test("parses json_schema format with custom retryCount", () => {
const result = MessageV2.Format.safeParse({
const result = MessageV2.Format.zod.safeParse({
type: "json_schema",
schema: { type: "object" },
retryCount: 5,
@@ -39,17 +39,17 @@ describe("structured-output.OutputFormat", () => {
})
test("rejects invalid type", () => {
const result = MessageV2.Format.safeParse({ type: "invalid" })
const result = MessageV2.Format.zod.safeParse({ type: "invalid" })
expect(result.success).toBe(false)
})
test("rejects json_schema without schema", () => {
const result = MessageV2.Format.safeParse({ type: "json_schema" })
const result = MessageV2.Format.zod.safeParse({ type: "json_schema" })
expect(result.success).toBe(false)
})
test("rejects negative retryCount", () => {
const result = MessageV2.Format.safeParse({
const result = MessageV2.Format.zod.safeParse({
type: "json_schema",
schema: { type: "object" },
retryCount: -1,
@@ -95,7 +95,7 @@ describe("structured-output.StructuredOutputError", () => {
describe("structured-output.UserMessage", () => {
test("user message accepts outputFormat", () => {
const result = MessageV2.User.safeParse({
const result = MessageV2.User.zod.safeParse({
id: MessageID.ascending(),
sessionID: SessionID.descending(),
role: "user",
@@ -111,7 +111,7 @@ describe("structured-output.UserMessage", () => {
})
test("user message works without outputFormat (optional)", () => {
const result = MessageV2.User.safeParse({
const result = MessageV2.User.zod.safeParse({
id: MessageID.ascending(),
sessionID: SessionID.descending(),
role: "user",
@@ -140,7 +140,7 @@ describe("structured-output.AssistantMessage", () => {
}
test("assistant message accepts structured", () => {
const result = MessageV2.Assistant.safeParse({
const result = MessageV2.Assistant.zod.safeParse({
...baseAssistantMessage,
structured: { company: "Anthropic", founded: 2021 },
})
@@ -151,7 +151,7 @@ describe("structured-output.AssistantMessage", () => {
})
test("assistant message works without structured_output (optional)", () => {
const result = MessageV2.Assistant.safeParse(baseAssistantMessage)
const result = MessageV2.Assistant.zod.safeParse(baseAssistantMessage)
expect(result.success).toBe(true)
})
})
@@ -195,6 +195,35 @@ describe("tool.apply_patch freeform", () => {
})
})
test("does not invent a first-line diff for BOM files", async () => {
await using fixture = await tmpdir()
const { ctx, calls } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const bom = String.fromCharCode(0xfeff)
const target = path.join(fixture.path, "example.cs")
await fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`, "utf-8")
const patchText =
"*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"
await execute({ patchText }, ctx)
expect(calls.length).toBe(1)
const shown = calls[0].metadata.files[0]?.patch ?? ""
expect(shown).not.toContain(bom)
expect(shown).not.toContain("-using System;")
expect(shown).not.toContain("+using System;")
const content = await fs.readFile(target, "utf-8")
expect(content.charCodeAt(0)).toBe(0xfeff)
expect(content.slice(1)).toBe("using System;\n\nclass Test {}\nclass Next {}\n")
},
})
})
test("inserts lines with insert-only hunk", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
+88 -16
View File
@@ -29,11 +29,6 @@ afterEach(async () => {
await Instance.disposeAll()
})
async function touch(file: string, time: number) {
const date = new Date(time)
await fs.utimes(file, date, date)
}
const runtime = ManagedRuntime.make(
Layer.mergeAll(
LSP.defaultLayer,
@@ -101,6 +96,37 @@ describe("tool.edit", () => {
})
})
test("preserves BOM when oldString is empty on existing files", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "existing.cs")
const bom = String.fromCharCode(0xfeff)
await fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await resolve()
const result = await Effect.runPromise(
edit.execute(
{
filePath: filepath,
oldString: "",
newString: "using Up;\n",
},
ctx,
),
)
expect(result.metadata.diff).toContain("-using System;")
expect(result.metadata.diff).toContain("+using Up;")
const content = await fs.readFile(filepath, "utf-8")
expect(content.charCodeAt(0)).toBe(0xfeff)
expect(content.slice(1)).toBe("using Up;\n")
},
})
})
test("creates new file with nested directories", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "nested", "dir", "file.txt")
@@ -188,6 +214,38 @@ describe("tool.edit", () => {
})
})
test("replaces the first visible line in BOM files", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "existing.cs")
const bom = String.fromCharCode(0xfeff)
await fs.writeFile(filepath, `${bom}using System;\nclass Test {}\n`, "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await resolve()
const result = await Effect.runPromise(
edit.execute(
{
filePath: filepath,
oldString: "using System;",
newString: "using Up;",
},
ctx,
),
)
expect(result.metadata.diff).toContain("-using System;")
expect(result.metadata.diff).toContain("+using Up;")
expect(result.metadata.diff).not.toContain(bom)
const content = await fs.readFile(filepath, "utf-8")
expect(content.charCodeAt(0)).toBe(0xfeff)
expect(content.slice(1)).toBe("using Up;\nclass Test {}\n")
},
})
})
test("throws error when file does not exist", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "nonexistent.txt")
@@ -639,42 +697,56 @@ describe("tool.edit", () => {
})
describe("concurrent editing", () => {
test("serializes concurrent edits to same file", async () => {
test("preserves concurrent edits to different sections of the same file", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "0", "utf-8")
await fs.writeFile(filepath, "top = 0\nmiddle = keep\nbottom = 0\n", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await resolve()
let asks = 0
const firstAsk = Promise.withResolvers<void>()
const delayedCtx = {
...ctx,
ask: () =>
Effect.gen(function* () {
asks++
if (asks !== 1) return
firstAsk.resolve()
yield* Effect.promise(() => Bun.sleep(50))
}),
}
// Two concurrent edits
const promise1 = Effect.runPromise(
edit.execute(
{
filePath: filepath,
oldString: "0",
newString: "1",
oldString: "top = 0",
newString: "top = 1",
},
ctx,
delayedCtx,
),
)
await firstAsk.promise
const promise2 = Effect.runPromise(
edit.execute(
{
filePath: filepath,
oldString: "0",
newString: "2",
oldString: "bottom = 0",
newString: "bottom = 2",
},
ctx,
delayedCtx,
),
)
// Both should complete without error (though one might fail due to content mismatch)
const results = await Promise.allSettled([promise1, promise2])
expect(results.some((r) => r.status === "fulfilled")).toBe(true)
expect(results[0]?.status).toBe("fulfilled")
expect(results[1]?.status).toBe("fulfilled")
expect(await fs.readFile(filepath, "utf-8")).toBe("top = 1\nmiddle = keep\nbottom = 2\n")
},
})
})
+5 -2
View File
@@ -34,8 +34,11 @@ const ctx = {
ask: () => Effect.void,
}
// kilocode_change - skip on windows: address windows ci failures #9496
const unix = process.platform !== "win32" ? it.live : it.live.skip
describe("tool.glob", () => {
it.live("matches files from a directory path", () =>
unix("matches files from a directory path", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(path.join(dir, "a.ts"), "export const a = 1\n"))
@@ -82,7 +85,7 @@ describe("tool.glob", () => {
)
// kilocode_change start - absolute glob patterns outside the project
it.live("supports absolute glob patterns outside the project", () =>
unix("supports absolute glob patterns outside the project", () =>
provideTmpdirInstance(
(_dir) =>
Effect.gen(function* () {
+4 -1
View File
@@ -30,8 +30,11 @@ const node = CrossSpawnSpawner.defaultLayer
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))
// kilocode_change - skip on windows: address windows ci failures #9496
const unix = process.platform !== "win32" ? it.live : it.live.skip
describe("tool.skill", () => {
it.live("execute returns skill content block with files", () =>
unix("execute returns skill content block with files", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
+48
View File
@@ -114,6 +114,54 @@ describe("tool.write", () => {
),
)
it.live("preserves BOM when overwriting existing files", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const filepath = path.join(dir, "existing.cs")
const bom = String.fromCharCode(0xfeff)
yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8"))
yield* run({ filePath: filepath, content: "using Up;\n" })
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content.charCodeAt(0)).toBe(0xfeff)
expect(content.slice(1)).toBe("using Up;\n")
}),
),
)
it.live("restores BOM after formatter strips it", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const filepath = path.join(dir, "formatted.cs")
const bom = String.fromCharCode(0xfeff)
yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8"))
yield* run({ filePath: filepath, content: "using Up;\n" })
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content.charCodeAt(0)).toBe(0xfeff)
expect(content.slice(1)).toBe("using Up;\n")
}),
{
config: {
formatter: {
stripbom: {
extensions: [".cs"],
command: [
"node",
"-e",
"const fs = require('fs'); const file = process.argv[1]; let text = fs.readFileSync(file, 'utf8'); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); fs.writeFileSync(file, text, 'utf8')",
"$FILE",
],
},
},
},
},
),
)
it.live("returns diff in metadata for existing files", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
+1 -116
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { Effect, Schema, SchemaGetter } from "effect"
import z from "zod"
import { zod, ZodOverride, ZodPreprocess } from "../../src/util/effect-zod"
import { zod, ZodOverride } from "../../src/util/effect-zod"
function json(schema: z.ZodTypeAny) {
const { $schema: _, ...rest } = z.toJSONSchema(schema)
@@ -751,119 +751,4 @@ describe("util.effect-zod", () => {
expect(schema.parse({ foo: "hi" })).toEqual({ foo: "hi" })
})
})
describe("ZodPreprocess annotation", () => {
test("preprocess runs on raw input before the inner schema parses", () => {
// Models the permission.ts __originalKeys pattern: capture the original
// insertion order of a user-provided object BEFORE Schema parsing
// canonicalises the keys.
const preprocess = (val: unknown) => {
if (typeof val === "object" && val !== null && !Array.isArray(val)) {
return { __keys: Object.keys(val), ...(val as Record<string, unknown>) }
}
return val
}
const Inner = Schema.Struct({
__keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
a: Schema.optional(Schema.String),
b: Schema.optional(Schema.String),
}).annotate({ [ZodPreprocess]: preprocess })
const schema = zod(Inner)
const parsed = schema.parse({ b: "1", a: "2" }) as {
__keys?: string[]
a?: string
b?: string
}
expect(parsed.__keys).toEqual(["b", "a"])
expect(parsed.a).toBe("2")
expect(parsed.b).toBe("1")
})
test("preprocess does not transform already-shaped input", () => {
// When the user passes an object that already has __keys, preprocess
// returns it unchanged because spreading preserves any existing key.
const preprocess = (val: unknown) => {
if (typeof val === "object" && val !== null && !("__keys" in val)) {
return { __keys: Object.keys(val), ...(val as Record<string, unknown>) }
}
return val
}
const Inner = Schema.Struct({
__keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
a: Schema.optional(Schema.String),
}).annotate({ [ZodPreprocess]: preprocess })
const schema = zod(Inner)
const parsed = schema.parse({ __keys: ["existing"], a: "hi" }) as {
__keys?: string[]
a?: string
}
expect(parsed.__keys).toEqual(["existing"])
})
test("preprocess composes with a union (either object or string)", () => {
// Mirrors permission.ts exactly: input can be either an object (with
// preprocess injecting metadata) or a plain string action.
const Action = Schema.Literals(["ask", "allow", "deny"])
const Obj = Schema.Struct({
__keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
read: Schema.optional(Action),
write: Schema.optional(Action),
})
const preprocess = (val: unknown) => {
if (typeof val === "object" && val !== null && !Array.isArray(val)) {
return { __keys: Object.keys(val), ...(val as Record<string, unknown>) }
}
return val
}
const Inner = Schema.Union([Obj, Action]).annotate({ [ZodPreprocess]: preprocess })
const schema = zod(Inner)
// String branch — passes through preprocess unchanged
expect(schema.parse("allow")).toBe("allow")
// Object branch — __keys injected, preserves order
const parsed = schema.parse({ write: "allow", read: "deny" }) as {
__keys?: string[]
read?: string
write?: string
}
expect(parsed.__keys).toEqual(["write", "read"])
expect(parsed.write).toBe("allow")
expect(parsed.read).toBe("deny")
})
test("JSON Schema output comes from the inner schema — preprocess is runtime-only", () => {
const Inner = Schema.Struct({
a: Schema.optional(Schema.String),
b: Schema.optional(Schema.Number),
}).annotate({ [ZodPreprocess]: (v: unknown) => v })
const shape = json(zod(Inner)) as any
expect(shape.type).toBe("object")
expect(shape.properties.a.type).toBe("string")
expect(shape.properties.b.type).toBe("number")
})
test("identifier + description propagate through the preprocess wrapper", () => {
const Inner = Schema.Struct({
x: Schema.optional(Schema.String),
}).annotate({
identifier: "WithPreproc",
description: "A schema with preprocess",
[ZodPreprocess]: (v: unknown) => v,
})
const schema = zod(Inner)
expect(schema.meta()?.ref).toBe("WithPreproc")
expect(schema.meta()?.description).toBe("A schema with preprocess")
})
test("preprocess inside a struct field applies only to that field", () => {
const Inner = Schema.String.annotate({
[ZodPreprocess]: (v: unknown) => (typeof v === "number" ? String(v) : v),
})
const schema = zod(Schema.Struct({ name: Inner, raw: Schema.Number }))
expect(schema.parse({ name: 42, raw: 7 })).toEqual({ name: "42", raw: 7 })
})
})
})