mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
Merge remote-tracking branch 'origin/main' into feat/cli-local-run
# Conflicts: # package.json
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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"])
|
||||
})
|
||||
@@ -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")
|
||||
}),
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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({})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user