mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
resolve merge conflicts
This commit is contained in:
@@ -1,16 +1,77 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, FileSystem, Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Config } from "@/config/config"
|
||||
import { emptyConsoleState } from "@/config/console-state"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Instruction } from "../../src/session/instruction"
|
||||
import type { MessageV2 } from "../../src/session/message-v2"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const run = <A>(effect: Effect.Effect<A, any, Instruction.Service>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provide(Instruction.defaultLayer)))
|
||||
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer))
|
||||
|
||||
const configLayer = Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
get: () => Effect.succeed({}),
|
||||
getGlobal: () => Effect.succeed({}),
|
||||
getConsoleState: () => Effect.succeed(emptyConsoleState),
|
||||
update: () => Effect.void,
|
||||
updateGlobal: (config) => Effect.succeed(config),
|
||||
invalidate: () => Effect.void,
|
||||
directories: () => Effect.succeed([]),
|
||||
waitForDependencies: () => Effect.void,
|
||||
warnings: () => Effect.succeed([]), // kilocode_change
|
||||
}),
|
||||
)
|
||||
|
||||
const instructionLayer = (global: Partial<Global.Interface>) =>
|
||||
Instruction.layer.pipe(
|
||||
Layer.provide(configLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(Global.layerWith(global)),
|
||||
)
|
||||
|
||||
const provideInstruction =
|
||||
(global: Partial<Global.Interface>) =>
|
||||
<A, E, R>(self: Effect.Effect<A, E, R>) =>
|
||||
self.pipe(Effect.provide(instructionLayer(global)))
|
||||
|
||||
const write = (filepath: string, content: string) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
yield* fs.makeDirectory(path.dirname(filepath), { recursive: true })
|
||||
yield* fs.writeFileString(filepath, content)
|
||||
})
|
||||
|
||||
const writeFiles = (dir: string, files: Record<string, string>) =>
|
||||
Effect.all(
|
||||
Object.entries(files).map(([file, content]) => write(path.join(dir, file), content)),
|
||||
{ discard: true },
|
||||
)
|
||||
|
||||
const withFiles = <A, E, R>(files: Record<string, string>, self: (dir: string) => Effect.Effect<A, E, R>) =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* writeFiles(dir, files)
|
||||
return yield* self(dir).pipe(provideInstruction({ home: dir, config: dir }))
|
||||
}),
|
||||
)
|
||||
|
||||
const tmpWithFiles = (files: Record<string, string>) =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
yield* writeFiles(dir, files)
|
||||
return dir
|
||||
})
|
||||
|
||||
function loaded(filepath: string): MessageV2.WithParts[] {
|
||||
const sessionID = SessionID.make("session-loaded-1")
|
||||
@@ -52,336 +113,208 @@ function loaded(filepath: string): MessageV2.WithParts[] {
|
||||
}
|
||||
|
||||
describe("Instruction.resolve", () => {
|
||||
test("returns empty when AGENTS.md is at project root (already in systemPaths)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Root Instructions")
|
||||
await Bun.write(path.join(dir, "src", "file.ts"), "const x = 1")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const system = yield* svc.systemPaths()
|
||||
expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
|
||||
it.live("returns empty when AGENTS.md is at project root (already in systemPaths)", () =>
|
||||
withFiles({ "AGENTS.md": "# Root Instructions", "src/file.ts": "const x = 1" }, (dir) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Instruction.Service
|
||||
const system = yield* svc.systemPaths()
|
||||
expect(system.has(path.join(dir, "AGENTS.md"))).toBe(true)
|
||||
|
||||
const results = yield* svc.resolve(
|
||||
[],
|
||||
path.join(tmp.path, "src", "file.ts"),
|
||||
MessageID.make("message-test-1"),
|
||||
)
|
||||
expect(results).toEqual([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
const results = yield* svc.resolve([], path.join(dir, "src", "file.ts"), MessageID.make("message-test-1"))
|
||||
expect(results).toEqual([])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
test("returns AGENTS.md from subdirectory (not in systemPaths)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
|
||||
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const system = yield* svc.systemPaths()
|
||||
expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
|
||||
it.live("returns AGENTS.md from subdirectory (not in systemPaths)", () =>
|
||||
withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Instruction.Service
|
||||
const system = yield* svc.systemPaths()
|
||||
expect(system.has(path.join(dir, "subdir", "AGENTS.md"))).toBe(false)
|
||||
|
||||
const results = yield* svc.resolve(
|
||||
[],
|
||||
path.join(tmp.path, "subdir", "nested", "file.ts"),
|
||||
MessageID.make("message-test-2"),
|
||||
)
|
||||
expect(results.length).toBe(1)
|
||||
expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
const results = yield* svc.resolve(
|
||||
[],
|
||||
path.join(dir, "subdir", "nested", "file.ts"),
|
||||
MessageID.make("message-test-2"),
|
||||
)
|
||||
expect(results.length).toBe(1)
|
||||
expect(results[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md"))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
test("doesn't reload AGENTS.md when reading it directly", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
|
||||
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(tmp.path, "subdir", "AGENTS.md")
|
||||
const system = yield* svc.systemPaths()
|
||||
expect(system.has(filepath)).toBe(false)
|
||||
it.live("doesn't reload AGENTS.md when reading it directly", () =>
|
||||
withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Instruction.Service
|
||||
const filepath = path.join(dir, "subdir", "AGENTS.md")
|
||||
const system = yield* svc.systemPaths()
|
||||
expect(system.has(filepath)).toBe(false)
|
||||
|
||||
const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3"))
|
||||
expect(results).toEqual([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3"))
|
||||
expect(results).toEqual([])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
test("does not reattach the same nearby instructions twice for one message", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
|
||||
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
|
||||
const id = MessageID.make("message-claim-1")
|
||||
it.live("does not reattach the same nearby instructions twice for one message", () =>
|
||||
withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Instruction.Service
|
||||
const filepath = path.join(dir, "subdir", "nested", "file.ts")
|
||||
const id = MessageID.make("message-claim-1")
|
||||
|
||||
const first = yield* svc.resolve([], filepath, id)
|
||||
const second = yield* svc.resolve([], filepath, id)
|
||||
const first = yield* svc.resolve([], filepath, id)
|
||||
const second = yield* svc.resolve([], filepath, id)
|
||||
|
||||
expect(first).toHaveLength(1)
|
||||
expect(first[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
|
||||
expect(second).toEqual([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
expect(first).toHaveLength(1)
|
||||
expect(first[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md"))
|
||||
expect(second).toEqual([])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
test("clear allows nearby instructions to be attached again for the same message", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
|
||||
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
|
||||
const id = MessageID.make("message-claim-2")
|
||||
it.live("clear allows nearby instructions to be attached again for the same message", () =>
|
||||
withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Instruction.Service
|
||||
const filepath = path.join(dir, "subdir", "nested", "file.ts")
|
||||
const id = MessageID.make("message-claim-2")
|
||||
|
||||
const first = yield* svc.resolve([], filepath, id)
|
||||
yield* svc.clear(id)
|
||||
const second = yield* svc.resolve([], filepath, id)
|
||||
const first = yield* svc.resolve([], filepath, id)
|
||||
yield* svc.clear(id)
|
||||
const second = yield* svc.resolve([], filepath, id)
|
||||
|
||||
expect(first).toHaveLength(1)
|
||||
expect(second).toHaveLength(1)
|
||||
expect(second[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
expect(first).toHaveLength(1)
|
||||
expect(second).toHaveLength(1)
|
||||
expect(second[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md"))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
test("skips instructions already reported by prior read metadata", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
|
||||
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const agents = path.join(tmp.path, "subdir", "AGENTS.md")
|
||||
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
|
||||
const id = MessageID.make("message-claim-3")
|
||||
it.live("skips instructions already reported by prior read metadata", () =>
|
||||
withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Instruction.Service
|
||||
const agents = path.join(dir, "subdir", "AGENTS.md")
|
||||
const filepath = path.join(dir, "subdir", "nested", "file.ts")
|
||||
const id = MessageID.make("message-claim-3")
|
||||
|
||||
const results = yield* svc.resolve(loaded(agents), filepath, id)
|
||||
expect(results).toEqual([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
const results = yield* svc.resolve(loaded(agents), filepath, id)
|
||||
expect(results).toEqual([])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
test.todo("fetches remote instructions from config URLs via HttpClient", () => {})
|
||||
})
|
||||
|
||||
describe("Instruction.system", () => {
|
||||
test("loads both project and global AGENTS.md when both exist", async () => {
|
||||
const originalConfigDir = process.env["KILO_CONFIG_DIR"]
|
||||
delete process.env["KILO_CONFIG_DIR"]
|
||||
it.live("loads both project and global AGENTS.md when both exist", () =>
|
||||
Effect.gen(function* () {
|
||||
const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" })
|
||||
const projectTmp = yield* tmpWithFiles({ "AGENTS.md": "# Project Instructions" })
|
||||
|
||||
await using globalTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
|
||||
},
|
||||
})
|
||||
await using projectTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Project Instructions")
|
||||
},
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
const svc = yield* Instruction.Service
|
||||
const paths = yield* svc.systemPaths()
|
||||
expect(paths.has(path.join(projectTmp, "AGENTS.md"))).toBe(true)
|
||||
expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true)
|
||||
|
||||
const originalGlobalConfig = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp.path
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: projectTmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const paths = yield* svc.systemPaths()
|
||||
expect(paths.has(path.join(projectTmp.path, "AGENTS.md"))).toBe(true)
|
||||
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
|
||||
|
||||
const rules = yield* svc.system()
|
||||
expect(rules).toHaveLength(2)
|
||||
expect(rules[0]).toBe(
|
||||
`Instructions from: ${path.join(globalTmp.path, "AGENTS.md")}\n# Global Instructions`,
|
||||
)
|
||||
expect(rules[1]).toBe(
|
||||
`Instructions from: ${path.join(projectTmp.path, "AGENTS.md")}\n# Project Instructions`,
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env["KILO_CONFIG_DIR"]
|
||||
} else {
|
||||
process.env["KILO_CONFIG_DIR"] = originalConfigDir
|
||||
}
|
||||
}
|
||||
})
|
||||
const rules = yield* svc.system()
|
||||
expect(rules).toHaveLength(2)
|
||||
expect(rules[0]).toBe(`Instructions from: ${path.join(globalTmp, "AGENTS.md")}\n# Global Instructions`)
|
||||
expect(rules[1]).toBe(`Instructions from: ${path.join(projectTmp, "AGENTS.md")}\n# Project Instructions`)
|
||||
}).pipe(provideInstance(projectTmp), provideInstruction({ home: globalTmp, config: globalTmp }))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Instruction.systemPaths KILO_CONFIG_DIR", () => {
|
||||
let originalConfigDir: string | undefined
|
||||
describe("Instruction.systemPaths global config", () => {
|
||||
it.live("uses Global.Service config AGENTS.md", () =>
|
||||
Effect.gen(function* () {
|
||||
const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" })
|
||||
const projectTmp = yield* tmpdirScoped()
|
||||
|
||||
beforeEach(() => {
|
||||
originalConfigDir = process.env["KILO_CONFIG_DIR"]
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
const svc = yield* Instruction.Service
|
||||
const paths = yield* svc.systemPaths()
|
||||
expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true)
|
||||
}).pipe(provideInstance(projectTmp), provideInstruction({ home: globalTmp, config: globalTmp }))
|
||||
}),
|
||||
)
|
||||
|
||||
afterEach(() => {
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env["KILO_CONFIG_DIR"]
|
||||
} else {
|
||||
process.env["KILO_CONFIG_DIR"] = originalConfigDir
|
||||
}
|
||||
})
|
||||
|
||||
test("prefers KILO_CONFIG_DIR AGENTS.md over global when both exist", async () => {
|
||||
await using profileTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Profile Instructions")
|
||||
},
|
||||
})
|
||||
await using globalTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
|
||||
},
|
||||
})
|
||||
await using projectTmp = await tmpdir()
|
||||
|
||||
process.env["KILO_CONFIG_DIR"] = profileTmp.path
|
||||
const originalGlobalConfig = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp.path
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: projectTmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const paths = yield* svc.systemPaths()
|
||||
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true)
|
||||
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false)
|
||||
}),
|
||||
),
|
||||
),
|
||||
// kilocode_change start - KILO_CONFIG_DIR profile fallback (replaces dropped Kilo-specific tests)
|
||||
const withConfigDir =
|
||||
(value: string | undefined) =>
|
||||
<A, E, R>(self: Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
const original = process.env["KILO_CONFIG_DIR"]
|
||||
if (value === undefined) delete process.env["KILO_CONFIG_DIR"]
|
||||
else process.env["KILO_CONFIG_DIR"] = value
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
if (original === undefined) delete process.env["KILO_CONFIG_DIR"]
|
||||
else process.env["KILO_CONFIG_DIR"] = original
|
||||
}),
|
||||
)
|
||||
return yield* self
|
||||
})
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||
}
|
||||
})
|
||||
|
||||
test("falls back to global AGENTS.md when KILO_CONFIG_DIR has no AGENTS.md", async () => {
|
||||
await using profileTmp = await tmpdir()
|
||||
await using globalTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
|
||||
},
|
||||
})
|
||||
await using projectTmp = await tmpdir()
|
||||
it.live("prefers KILO_CONFIG_DIR AGENTS.md over global when both exist", () =>
|
||||
Effect.gen(function* () {
|
||||
const profileTmp = yield* tmpWithFiles({ "AGENTS.md": "# Profile Instructions" })
|
||||
const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" })
|
||||
const projectTmp = yield* tmpdirScoped()
|
||||
|
||||
process.env["KILO_CONFIG_DIR"] = profileTmp.path
|
||||
const originalGlobalConfig = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp.path
|
||||
yield* Effect.gen(function* () {
|
||||
const svc = yield* Instruction.Service
|
||||
const paths = yield* svc.systemPaths()
|
||||
expect(paths.has(path.join(profileTmp, "AGENTS.md"))).toBe(true)
|
||||
expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(false)
|
||||
}).pipe(
|
||||
provideInstance(projectTmp),
|
||||
provideInstruction({ home: globalTmp, config: globalTmp }),
|
||||
withConfigDir(profileTmp),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: projectTmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const paths = yield* svc.systemPaths()
|
||||
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false)
|
||||
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||
}
|
||||
})
|
||||
it.live("falls back to global AGENTS.md when KILO_CONFIG_DIR has no AGENTS.md", () =>
|
||||
Effect.gen(function* () {
|
||||
const profileTmp = yield* tmpdirScoped()
|
||||
const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" })
|
||||
const projectTmp = yield* tmpdirScoped()
|
||||
|
||||
test("uses global AGENTS.md when KILO_CONFIG_DIR is not set", async () => {
|
||||
await using globalTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
|
||||
},
|
||||
})
|
||||
await using projectTmp = await tmpdir()
|
||||
yield* Effect.gen(function* () {
|
||||
const svc = yield* Instruction.Service
|
||||
const paths = yield* svc.systemPaths()
|
||||
expect(paths.has(path.join(profileTmp, "AGENTS.md"))).toBe(false)
|
||||
expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true)
|
||||
}).pipe(
|
||||
provideInstance(projectTmp),
|
||||
provideInstruction({ home: globalTmp, config: globalTmp }),
|
||||
withConfigDir(profileTmp),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
delete process.env["KILO_CONFIG_DIR"]
|
||||
const originalGlobalConfig = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp.path
|
||||
it.live("uses global AGENTS.md when KILO_CONFIG_DIR is not set", () =>
|
||||
Effect.gen(function* () {
|
||||
const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" })
|
||||
const projectTmp = yield* tmpdirScoped()
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: projectTmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const paths = yield* svc.systemPaths()
|
||||
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||
}
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
const svc = yield* Instruction.Service
|
||||
const paths = yield* svc.systemPaths()
|
||||
expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true)
|
||||
}).pipe(
|
||||
provideInstance(projectTmp),
|
||||
provideInstruction({ home: globalTmp, config: globalTmp }),
|
||||
withConfigDir(undefined),
|
||||
)
|
||||
}),
|
||||
)
|
||||
// kilocode_change end
|
||||
})
|
||||
|
||||
@@ -469,6 +469,13 @@ describe("session.message-v2.toModelMessage", () => {
|
||||
},
|
||||
{
|
||||
...basePart(assistantID, "a2"),
|
||||
type: "reasoning",
|
||||
text: "thinking",
|
||||
metadata: { openai: { reasoning: "meta" } },
|
||||
time: { start: 0 },
|
||||
},
|
||||
{
|
||||
...basePart(assistantID, "a3"),
|
||||
type: "tool",
|
||||
callID: "call-1",
|
||||
tool: "bash",
|
||||
@@ -495,6 +502,7 @@ describe("session.message-v2.toModelMessage", () => {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "done" },
|
||||
{ type: "text", text: "thinking" },
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call-1",
|
||||
|
||||
@@ -2,28 +2,31 @@ import { describe, expect, test } from "bun:test"
|
||||
import type { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { APICallError } from "ai"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { Effect, Schedule } from "effect"
|
||||
import { Effect, Layer, Schedule } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { SessionRetry } from "../../src/session/retry"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { ProviderID } from "../../src/provider/schema"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
import { SessionStatus } from "../../src/session/status"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const providerID = ProviderID.make("test")
|
||||
const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
function apiError(headers?: Record<string, string>): MessageV2.APIError {
|
||||
return new MessageV2.APIError({
|
||||
message: "boom",
|
||||
isRetryable: true,
|
||||
responseHeaders: headers,
|
||||
}).toObject() as MessageV2.APIError
|
||||
return MessageV2.APIError.Schema.parse(
|
||||
new MessageV2.APIError({
|
||||
message: "boom",
|
||||
isRetryable: true,
|
||||
responseHeaders: headers,
|
||||
}).toObject(),
|
||||
)
|
||||
}
|
||||
|
||||
function wrap(message: unknown): ReturnType<NamedError["toObject"]> {
|
||||
return { data: { message } } as ReturnType<NamedError["toObject"]>
|
||||
return { name: "", data: { message } }
|
||||
}
|
||||
|
||||
describe("session.retry.delay", () => {
|
||||
@@ -80,47 +83,36 @@ describe("session.retry.delay", () => {
|
||||
expect(SessionRetry.delay(1, error)).toBe(SessionRetry.RETRY_MAX_DELAY)
|
||||
})
|
||||
|
||||
test("policy updates retry status and increments attempts", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
it.live("policy updates retry status and increments attempts", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const sessionID = SessionID.make("session-retry-test")
|
||||
const error = apiError({ "retry-after-ms": "0" })
|
||||
const status = yield* SessionStatus.Service
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const step = yield* Schedule.toStepWithMetadata(
|
||||
SessionRetry.policy({
|
||||
parse: (err) => err as MessageV2.APIError,
|
||||
set: (info) =>
|
||||
Effect.promise(() =>
|
||||
AppRuntime.runPromise(
|
||||
SessionStatus.Service.use((svc) =>
|
||||
svc.set(sessionID, {
|
||||
type: "retry",
|
||||
attempt: info.attempt,
|
||||
message: info.message,
|
||||
next: info.next,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
const step = yield* Schedule.toStepWithMetadata(
|
||||
SessionRetry.policy({
|
||||
parse: (err) => MessageV2.APIError.Schema.parse(err),
|
||||
set: (info) =>
|
||||
status.set(sessionID, {
|
||||
type: "retry",
|
||||
attempt: info.attempt,
|
||||
message: info.message,
|
||||
next: info.next,
|
||||
}),
|
||||
)
|
||||
yield* step(error)
|
||||
yield* step(error)
|
||||
}),
|
||||
)
|
||||
yield* step(error)
|
||||
yield* step(error)
|
||||
|
||||
expect(await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.get(sessionID)))).toMatchObject({
|
||||
expect(yield* status.get(sessionID)).toMatchObject({
|
||||
type: "retry",
|
||||
attempt: 2,
|
||||
message: "boom",
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
describe("session.retry.retryable", () => {
|
||||
@@ -173,58 +165,68 @@ describe("session.retry.retryable", () => {
|
||||
const error = new MessageV2.ContextOverflowError({
|
||||
message: "Input exceeds context window of this model",
|
||||
responseBody: '{"error":{"code":"context_length_exceeded"}}',
|
||||
}).toObject() as ReturnType<NamedError["toObject"]>
|
||||
}).toObject()
|
||||
|
||||
expect(SessionRetry.retryable(error)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("retries 500 errors even when isRetryable is false", () => {
|
||||
const error = new MessageV2.APIError({
|
||||
message: "Internal server error",
|
||||
isRetryable: false,
|
||||
statusCode: 500,
|
||||
responseBody: '{"type":"api_error","message":"Internal server error"}',
|
||||
}).toObject() as MessageV2.APIError
|
||||
const error = MessageV2.APIError.Schema.parse(
|
||||
new MessageV2.APIError({
|
||||
message: "Internal server error",
|
||||
isRetryable: false,
|
||||
statusCode: 500,
|
||||
responseBody: '{"type":"api_error","message":"Internal server error"}',
|
||||
}).toObject(),
|
||||
)
|
||||
|
||||
expect(SessionRetry.retryable(error)).toBe("Internal server error")
|
||||
})
|
||||
|
||||
test("retries 502 bad gateway errors", () => {
|
||||
const error = new MessageV2.APIError({
|
||||
message: "Bad gateway",
|
||||
isRetryable: false,
|
||||
statusCode: 502,
|
||||
}).toObject() as MessageV2.APIError
|
||||
const error = MessageV2.APIError.Schema.parse(
|
||||
new MessageV2.APIError({
|
||||
message: "Bad gateway",
|
||||
isRetryable: false,
|
||||
statusCode: 502,
|
||||
}).toObject(),
|
||||
)
|
||||
|
||||
expect(SessionRetry.retryable(error)).toBe("Bad gateway")
|
||||
})
|
||||
|
||||
test("retries 503 service unavailable errors", () => {
|
||||
const error = new MessageV2.APIError({
|
||||
message: "Service unavailable",
|
||||
isRetryable: false,
|
||||
statusCode: 503,
|
||||
}).toObject() as MessageV2.APIError
|
||||
const error = MessageV2.APIError.Schema.parse(
|
||||
new MessageV2.APIError({
|
||||
message: "Service unavailable",
|
||||
isRetryable: false,
|
||||
statusCode: 503,
|
||||
}).toObject(),
|
||||
)
|
||||
|
||||
expect(SessionRetry.retryable(error)).toBe("Service unavailable")
|
||||
})
|
||||
|
||||
test("does not retry 4xx errors when isRetryable is false", () => {
|
||||
const error = new MessageV2.APIError({
|
||||
message: "Bad request",
|
||||
isRetryable: false,
|
||||
statusCode: 400,
|
||||
}).toObject() as MessageV2.APIError
|
||||
const error = MessageV2.APIError.Schema.parse(
|
||||
new MessageV2.APIError({
|
||||
message: "Bad request",
|
||||
isRetryable: false,
|
||||
statusCode: 400,
|
||||
}).toObject(),
|
||||
)
|
||||
|
||||
expect(SessionRetry.retryable(error)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("retries ZlibError decompression failures", () => {
|
||||
const error = new MessageV2.APIError({
|
||||
message: "Response decompression failed",
|
||||
isRetryable: true,
|
||||
metadata: { code: "ZlibError" },
|
||||
}).toObject() as MessageV2.APIError
|
||||
const error = MessageV2.APIError.Schema.parse(
|
||||
new MessageV2.APIError({
|
||||
message: "Response decompression failed",
|
||||
isRetryable: true,
|
||||
metadata: { code: "ZlibError" },
|
||||
}).toObject(),
|
||||
)
|
||||
|
||||
const retryable = SessionRetry.retryable(error)
|
||||
expect(retryable).toBeDefined()
|
||||
@@ -261,20 +263,23 @@ describe("session.message-v2.fromError", () => {
|
||||
const result = MessageV2.fromError(error, { providerID })
|
||||
|
||||
expect(MessageV2.APIError.isInstance(result)).toBe(true)
|
||||
expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
|
||||
expect((result as MessageV2.APIError).data.message).toBe("Connection reset by server")
|
||||
expect((result as MessageV2.APIError).data.metadata?.code).toBe("ECONNRESET")
|
||||
expect((result as MessageV2.APIError).data.metadata?.message).toInclude("socket connection")
|
||||
if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError")
|
||||
expect(result.data.isRetryable).toBe(true)
|
||||
expect(result.data.message).toBe("Connection reset by server")
|
||||
expect(result.data.metadata?.code).toBe("ECONNRESET")
|
||||
expect(result.data.metadata?.message).toInclude("socket connection")
|
||||
},
|
||||
15_000,
|
||||
)
|
||||
|
||||
test("ECONNRESET socket error is retryable", () => {
|
||||
const error = new MessageV2.APIError({
|
||||
message: "Connection reset by server",
|
||||
isRetryable: true,
|
||||
metadata: { code: "ECONNRESET", message: "The socket connection was closed unexpectedly" },
|
||||
}).toObject() as MessageV2.APIError
|
||||
const error = MessageV2.APIError.Schema.parse(
|
||||
new MessageV2.APIError({
|
||||
message: "Connection reset by server",
|
||||
isRetryable: true,
|
||||
metadata: { code: "ECONNRESET", message: "The socket connection was closed unexpectedly" },
|
||||
}).toObject(),
|
||||
)
|
||||
|
||||
const retryable = SessionRetry.retryable(error)
|
||||
expect(retryable).toBeDefined()
|
||||
@@ -308,7 +313,8 @@ describe("session.message-v2.fromError", () => {
|
||||
responseBody: '{"error":"boom"}',
|
||||
isRetryable: false,
|
||||
})
|
||||
const result = MessageV2.fromError(error, { providerID: ProviderID.make("openai") }) as MessageV2.APIError
|
||||
const result = MessageV2.fromError(error, { providerID: ProviderID.make("openai") })
|
||||
if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError")
|
||||
expect(result.data.isRetryable).toBe(true)
|
||||
})
|
||||
|
||||
@@ -330,7 +336,8 @@ describe("session.message-v2.fromError", () => {
|
||||
)
|
||||
|
||||
expect(MessageV2.APIError.isInstance(result)).toBe(true)
|
||||
expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
|
||||
if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError")
|
||||
expect(result.data.isRetryable).toBe(true)
|
||||
expect(SessionRetry.retryable(result)).toBe("An error occurred while processing your request.")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,69 +1,73 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import type { Agent } from "../../src/agent/agent"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { Skill } from "../../src/skill"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { SystemPrompt } from "../../src/session/system"
|
||||
import { provideInstance, tmpdir } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
|
||||
return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer)))
|
||||
const skills: Skill.Info[] = [
|
||||
{
|
||||
name: "zeta-skill",
|
||||
description: "Zeta skill.",
|
||||
location: "/tmp/zeta-skill/SKILL.md",
|
||||
content: "# zeta-skill",
|
||||
},
|
||||
{
|
||||
name: "alpha-skill",
|
||||
description: "Alpha skill.",
|
||||
location: "/tmp/alpha-skill/SKILL.md",
|
||||
content: "# alpha-skill",
|
||||
},
|
||||
{
|
||||
name: "middle-skill",
|
||||
description: "Middle skill.",
|
||||
location: "/tmp/middle-skill/SKILL.md",
|
||||
content: "# middle-skill",
|
||||
},
|
||||
]
|
||||
|
||||
const build: Agent.Info = {
|
||||
name: "build",
|
||||
mode: "primary",
|
||||
permission: Permission.fromConfig({ "*": "allow" }),
|
||||
options: {},
|
||||
}
|
||||
|
||||
const it = testEffect(
|
||||
SystemPrompt.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.succeed(
|
||||
Skill.Service,
|
||||
Skill.Service.of({
|
||||
get: (name) => Effect.succeed(skills.find((skill) => skill.name === name)),
|
||||
all: () => Effect.succeed(skills),
|
||||
dirs: () => Effect.succeed([]),
|
||||
available: () => Effect.succeed(skills),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
describe("session.system", () => {
|
||||
test("skills output is sorted by name and stable across calls", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
for (const [name, description] of [
|
||||
["zeta-skill", "Zeta skill."],
|
||||
["alpha-skill", "Alpha skill."],
|
||||
["middle-skill", "Middle skill."],
|
||||
]) {
|
||||
const skillDir = path.join(dir, ".opencode", "skill", name)
|
||||
await Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: ${name}
|
||||
description: ${description}
|
||||
---
|
||||
it.effect("skills output is sorted by name and stable across calls", () =>
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SystemPrompt.Service
|
||||
const first = yield* prompt.skills(build)
|
||||
const second = yield* prompt.skills(build)
|
||||
const output = first ?? (yield* Effect.fail(new NamedError.Unknown({ message: "missing skills output" })))
|
||||
|
||||
# ${name}
|
||||
`,
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
expect(first).toBe(second)
|
||||
|
||||
const home = process.env.KILO_TEST_HOME
|
||||
process.env.KILO_TEST_HOME = tmp.path
|
||||
const alpha = output.indexOf("<name>alpha-skill</name>")
|
||||
const middle = output.indexOf("<name>middle-skill</name>")
|
||||
const zeta = output.indexOf("<name>zeta-skill</name>")
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await load(tmp.path, (svc) => svc.get("build"))
|
||||
const runSkills = Effect.gen(function* () {
|
||||
const svc = yield* SystemPrompt.Service
|
||||
return yield* svc.skills(build!)
|
||||
}).pipe(Effect.provide(SystemPrompt.defaultLayer))
|
||||
|
||||
const first = await Effect.runPromise(runSkills)
|
||||
const second = await Effect.runPromise(runSkills)
|
||||
|
||||
expect(first).toBe(second)
|
||||
|
||||
const alpha = first!.indexOf("<name>alpha-skill</name>")
|
||||
const middle = first!.indexOf("<name>middle-skill</name>")
|
||||
const zeta = first!.indexOf("<name>zeta-skill</name>")
|
||||
|
||||
expect(alpha).toBeGreaterThan(-1)
|
||||
expect(middle).toBeGreaterThan(alpha)
|
||||
expect(zeta).toBeGreaterThan(middle)
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
process.env.KILO_TEST_HOME = home
|
||||
}
|
||||
})
|
||||
expect(alpha).toBeGreaterThan(-1)
|
||||
expect(middle).toBeGreaterThan(alpha)
|
||||
expect(zeta).toBeGreaterThan(middle)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user