test: use testEffect for instruction tests (#25046)
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
@@ -8,30 +7,15 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import type { MessageV2 } from "./message-v2"
|
||||
import type { MessageID } from "./schema"
|
||||
|
||||
const log = Log.create({ service: "instruction" })
|
||||
|
||||
const FILES = [
|
||||
"AGENTS.md",
|
||||
...(Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT ? [] : ["CLAUDE.md"]),
|
||||
"CONTEXT.md", // deprecated
|
||||
]
|
||||
|
||||
function globalFiles() {
|
||||
const files = []
|
||||
if (Flag.OPENCODE_CONFIG_DIR) {
|
||||
files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
|
||||
}
|
||||
files.push(path.join(Global.Path.config, "AGENTS.md"))
|
||||
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
|
||||
files.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
function extract(messages: MessageV2.WithParts[]) {
|
||||
const paths = new Set<string>()
|
||||
for (const msg of messages) {
|
||||
@@ -63,176 +47,180 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Instruction") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.Service | HttpClient.HttpClient> =
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* Config.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
AppFileSystem.Service | Config.Service | Global.Service | HttpClient.HttpClient
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* Config.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const global = yield* Global.Service
|
||||
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
||||
const globalFiles = [
|
||||
path.join(global.config, "AGENTS.md"),
|
||||
...(!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT ? [path.join(global.home, ".claude", "CLAUDE.md")] : []),
|
||||
]
|
||||
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("Instruction.state")(() =>
|
||||
Effect.succeed({
|
||||
// Track which instruction files have already been attached for a given assistant message.
|
||||
claims: new Map<MessageID, Set<string>>(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("Instruction.state")(() =>
|
||||
Effect.succeed({
|
||||
// Track which instruction files have already been attached for a given assistant message.
|
||||
claims: new Map<MessageID, Set<string>>(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const relative = Effect.fnUntraced(function* (instruction: string) {
|
||||
const ctx = yield* InstanceState.context
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
return yield* fs
|
||||
.globUp(instruction, ctx.directory, ctx.worktree)
|
||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
}
|
||||
if (!Flag.OPENCODE_CONFIG_DIR) {
|
||||
log.warn(
|
||||
`Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
|
||||
)
|
||||
return []
|
||||
}
|
||||
const relative = Effect.fnUntraced(function* (instruction: string) {
|
||||
const ctx = yield* InstanceState.context
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
return yield* fs
|
||||
.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR)
|
||||
.globUp(instruction, ctx.directory, ctx.worktree)
|
||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
})
|
||||
}
|
||||
return yield* fs
|
||||
.globUp(instruction, global.config, global.config)
|
||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
})
|
||||
|
||||
const read = Effect.fnUntraced(function* (filepath: string) {
|
||||
return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed("")))
|
||||
})
|
||||
const read = Effect.fnUntraced(function* (filepath: string) {
|
||||
return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed("")))
|
||||
})
|
||||
|
||||
const fetch = Effect.fnUntraced(function* (url: string) {
|
||||
const res = yield* http.execute(HttpClientRequest.get(url)).pipe(
|
||||
Effect.timeout(5000),
|
||||
Effect.catch(() => Effect.succeed(null)),
|
||||
)
|
||||
if (!res) return ""
|
||||
const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0))))
|
||||
return new TextDecoder().decode(body)
|
||||
})
|
||||
const fetch = Effect.fnUntraced(function* (url: string) {
|
||||
const res = yield* http.execute(HttpClientRequest.get(url)).pipe(
|
||||
Effect.timeout(5000),
|
||||
Effect.catch(() => Effect.succeed(null)),
|
||||
)
|
||||
if (!res) return ""
|
||||
const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0))))
|
||||
return new TextDecoder().decode(body)
|
||||
})
|
||||
|
||||
const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
s.claims.delete(messageID)
|
||||
})
|
||||
const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
s.claims.delete(messageID)
|
||||
})
|
||||
|
||||
const systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
|
||||
const config = yield* cfg.get()
|
||||
const ctx = yield* InstanceState.context
|
||||
const paths = new Set<string>()
|
||||
const systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
|
||||
const config = yield* cfg.get()
|
||||
const ctx = yield* InstanceState.context
|
||||
const paths = new Set<string>()
|
||||
|
||||
for (const file of globalFiles()) {
|
||||
if (yield* fs.existsSafe(file)) {
|
||||
paths.add(path.resolve(file))
|
||||
for (const file of globalFiles) {
|
||||
if (yield* fs.existsSafe(file)) {
|
||||
paths.add(path.resolve(file))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor.
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
for (const file of FILES) {
|
||||
const matches = yield* fs.findUp(file, ctx.directory, ctx.worktree)
|
||||
if (matches.length > 0) {
|
||||
matches.forEach((item) => paths.add(path.resolve(item)))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor.
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
for (const file of FILES) {
|
||||
const matches = yield* fs.findUp(file, ctx.directory, ctx.worktree)
|
||||
if (matches.length > 0) {
|
||||
matches.forEach((item) => paths.add(path.resolve(item)))
|
||||
break
|
||||
}
|
||||
}
|
||||
if (config.instructions) {
|
||||
for (const raw of config.instructions) {
|
||||
if (raw.startsWith("https://") || raw.startsWith("http://")) continue
|
||||
const instruction = raw.startsWith("~/") ? path.join(global.home, raw.slice(2)) : raw
|
||||
const matches = yield* (
|
||||
path.isAbsolute(instruction)
|
||||
? fs.glob(path.basename(instruction), {
|
||||
cwd: path.dirname(instruction),
|
||||
absolute: true,
|
||||
include: "file",
|
||||
})
|
||||
: relative(instruction)
|
||||
).pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
matches.forEach((item) => paths.add(path.resolve(item)))
|
||||
}
|
||||
}
|
||||
|
||||
if (config.instructions) {
|
||||
for (const raw of config.instructions) {
|
||||
if (raw.startsWith("https://") || raw.startsWith("http://")) continue
|
||||
const instruction = raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw
|
||||
const matches = yield* (
|
||||
path.isAbsolute(instruction)
|
||||
? fs.glob(path.basename(instruction), {
|
||||
cwd: path.dirname(instruction),
|
||||
absolute: true,
|
||||
include: "file",
|
||||
})
|
||||
: relative(instruction)
|
||||
).pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
matches.forEach((item) => paths.add(path.resolve(item)))
|
||||
}
|
||||
}
|
||||
return paths
|
||||
})
|
||||
|
||||
return paths
|
||||
})
|
||||
const system = Effect.fn("Instruction.system")(function* () {
|
||||
const config = yield* cfg.get()
|
||||
const paths = yield* systemPaths()
|
||||
const urls = (config.instructions ?? []).filter(
|
||||
(item) => item.startsWith("https://") || item.startsWith("http://"),
|
||||
)
|
||||
|
||||
const system = Effect.fn("Instruction.system")(function* () {
|
||||
const config = yield* cfg.get()
|
||||
const paths = yield* systemPaths()
|
||||
const urls = (config.instructions ?? []).filter(
|
||||
(item) => item.startsWith("https://") || item.startsWith("http://"),
|
||||
)
|
||||
const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 })
|
||||
const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 })
|
||||
|
||||
const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 })
|
||||
const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 })
|
||||
return [
|
||||
...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])),
|
||||
...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])),
|
||||
]
|
||||
})
|
||||
|
||||
return [
|
||||
...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])),
|
||||
...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])),
|
||||
]
|
||||
})
|
||||
const find = Effect.fn("Instruction.find")(function* (dir: string) {
|
||||
for (const file of FILES) {
|
||||
const filepath = path.resolve(path.join(dir, file))
|
||||
if (yield* fs.existsSafe(filepath)) return filepath
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const find = Effect.fn("Instruction.find")(function* (dir: string) {
|
||||
for (const file of FILES) {
|
||||
const filepath = path.resolve(path.join(dir, file))
|
||||
if (yield* fs.existsSafe(filepath)) return filepath
|
||||
}
|
||||
})
|
||||
const resolve = Effect.fn("Instruction.resolve")(function* (
|
||||
messages: MessageV2.WithParts[],
|
||||
filepath: string,
|
||||
messageID: MessageID,
|
||||
) {
|
||||
const sys = yield* systemPaths()
|
||||
const already = extract(messages)
|
||||
const results: { filepath: string; content: string }[] = []
|
||||
const s = yield* InstanceState.get(state)
|
||||
const root = path.resolve(yield* InstanceState.directory)
|
||||
|
||||
const resolve = Effect.fn("Instruction.resolve")(function* (
|
||||
messages: MessageV2.WithParts[],
|
||||
filepath: string,
|
||||
messageID: MessageID,
|
||||
) {
|
||||
const sys = yield* systemPaths()
|
||||
const already = extract(messages)
|
||||
const results: { filepath: string; content: string }[] = []
|
||||
const s = yield* InstanceState.get(state)
|
||||
const root = path.resolve(yield* InstanceState.directory)
|
||||
|
||||
const target = path.resolve(filepath)
|
||||
let current = path.dirname(target)
|
||||
|
||||
// Walk upward from the file being read and attach nearby instruction files once per message.
|
||||
while (current.startsWith(root) && current !== root) {
|
||||
const found = yield* find(current)
|
||||
if (!found || found === target || sys.has(found) || already.has(found)) {
|
||||
current = path.dirname(current)
|
||||
continue
|
||||
}
|
||||
|
||||
let set = s.claims.get(messageID)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
s.claims.set(messageID, set)
|
||||
}
|
||||
if (set.has(found)) {
|
||||
current = path.dirname(current)
|
||||
continue
|
||||
}
|
||||
|
||||
set.add(found)
|
||||
const content = yield* read(found)
|
||||
if (content) {
|
||||
results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` })
|
||||
}
|
||||
const target = path.resolve(filepath)
|
||||
let current = path.dirname(target)
|
||||
|
||||
// Walk upward from the file being read and attach nearby instruction files once per message.
|
||||
while (current.startsWith(root) && current !== root) {
|
||||
const found = yield* find(current)
|
||||
if (!found || found === target || sys.has(found) || already.has(found)) {
|
||||
current = path.dirname(current)
|
||||
continue
|
||||
}
|
||||
|
||||
return results
|
||||
})
|
||||
let set = s.claims.get(messageID)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
s.claims.set(messageID, set)
|
||||
}
|
||||
if (set.has(found)) {
|
||||
current = path.dirname(current)
|
||||
continue
|
||||
}
|
||||
|
||||
return Service.of({ clear, systemPaths, system, find, resolve })
|
||||
}),
|
||||
)
|
||||
set.add(found)
|
||||
const content = yield* read(found)
|
||||
if (content) {
|
||||
results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` })
|
||||
}
|
||||
|
||||
current = path.dirname(current)
|
||||
}
|
||||
|
||||
return results
|
||||
})
|
||||
|
||||
return Service.of({ clear, systemPaths, system, find, resolve })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Global.layer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user