mirror of
https://github.com/anomalyco/opencode.git
synced 2026-06-01 19:05:38 +00:00
test: use testEffect for system prompt test (#25047)
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
import os from "os"
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
@@ -148,6 +147,7 @@ const discoverSkills = Effect.fnUntraced(function* (
|
|||||||
config: Config.Interface,
|
config: Config.Interface,
|
||||||
discovery: Discovery.Interface,
|
discovery: Discovery.Interface,
|
||||||
fsys: AppFileSystem.Interface,
|
fsys: AppFileSystem.Interface,
|
||||||
|
global: Global.Interface,
|
||||||
directory: string,
|
directory: string,
|
||||||
worktree: string,
|
worktree: string,
|
||||||
) {
|
) {
|
||||||
@@ -159,7 +159,7 @@ const discoverSkills = Effect.fnUntraced(function* (
|
|||||||
externalDirs.push(AGENTS_EXTERNAL_DIR)
|
externalDirs.push(AGENTS_EXTERNAL_DIR)
|
||||||
|
|
||||||
for (const dir of externalDirs) {
|
for (const dir of externalDirs) {
|
||||||
const root = path.join(Global.Path.home, dir)
|
const root = path.join(global.home, dir)
|
||||||
if (!(yield* fsys.isDir(root))) continue
|
if (!(yield* fsys.isDir(root))) continue
|
||||||
yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
||||||
}
|
}
|
||||||
@@ -180,7 +180,7 @@ const discoverSkills = Effect.fnUntraced(function* (
|
|||||||
|
|
||||||
const cfg = yield* config.get()
|
const cfg = yield* config.get()
|
||||||
for (const item of cfg.skills?.paths ?? []) {
|
for (const item of cfg.skills?.paths ?? []) {
|
||||||
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
|
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
|
||||||
const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
|
const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
|
||||||
if (!(yield* fsys.isDir(dir))) {
|
if (!(yield* fsys.isDir(dir))) {
|
||||||
log.warn("skill path not found", { path: dir })
|
log.warn("skill path not found", { path: dir })
|
||||||
@@ -221,13 +221,14 @@ export const layer = Layer.effect(
|
|||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
const fsys = yield* AppFileSystem.Service
|
const fsys = yield* AppFileSystem.Service
|
||||||
|
const global = yield* Global.Service
|
||||||
const discovered = yield* InstanceState.make(
|
const discovered = yield* InstanceState.make(
|
||||||
Effect.fn("Skill.discovery")(function* (ctx) {
|
Effect.fn("Skill.discovery")(function* (ctx) {
|
||||||
return yield* discoverSkills(config, discovery, fsys, ctx.directory, ctx.worktree)
|
return yield* discoverSkills(config, discovery, fsys, global, ctx.directory, ctx.worktree)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const state = yield* InstanceState.make(
|
const state = yield* InstanceState.make(
|
||||||
Effect.fn("Skill.state")(function* (ctx) {
|
Effect.fn("Skill.state")(function* () {
|
||||||
const s: State = { skills: {}, dirs: new Set() }
|
const s: State = { skills: {}, dirs: new Set() }
|
||||||
yield* loadSkills(s, yield* InstanceState.get(discovered), bus)
|
yield* loadSkills(s, yield* InstanceState.get(discovered), bus)
|
||||||
return s
|
return s
|
||||||
@@ -264,6 +265,7 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(Bus.layer),
|
Layer.provide(Bus.layer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
Layer.provide(Global.layer),
|
||||||
)
|
)
|
||||||
|
|
||||||
export function fmt(list: Info[], opts: { verbose: boolean }) {
|
export function fmt(list: Info[], opts: { verbose: boolean }) {
|
||||||
|
|||||||
@@ -3,18 +3,44 @@ import fs from "fs/promises"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { tmpdir } from "../../fixture/fixture"
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
import * as App from "../../../src/cli/cmd/tui/app"
|
import * as App from "../../../src/cli/cmd/tui/app"
|
||||||
import { Rpc } from "@/util/rpc"
|
|
||||||
import { UI } from "../../../src/cli/ui"
|
import { UI } from "../../../src/cli/ui"
|
||||||
import * as Timeout from "../../../src/util/timeout"
|
import * as Timeout from "../../../src/util/timeout"
|
||||||
import * as Network from "../../../src/cli/network"
|
import * as Network from "../../../src/cli/network"
|
||||||
import * as Win32 from "../../../src/cli/cmd/tui/win32"
|
import * as Win32 from "../../../src/cli/cmd/tui/win32"
|
||||||
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
|
|
||||||
|
|
||||||
const stop = new Error("stop")
|
const stop = new Error("stop")
|
||||||
|
const packageRoot = path.resolve(import.meta.dir, "../../..")
|
||||||
const seen = {
|
const seen = {
|
||||||
tui: [] as string[],
|
tui: [] as string[],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class TestWorker extends EventTarget {
|
||||||
|
onerror: Worker["onerror"] = null
|
||||||
|
onmessage: Worker["onmessage"] = null
|
||||||
|
onmessageerror: Worker["onmessageerror"] = null
|
||||||
|
|
||||||
|
postMessage(data: string) {
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
if (!parsed || typeof parsed !== "object" || !("method" in parsed) || !("id" in parsed)) return
|
||||||
|
if (typeof parsed.method !== "string" || typeof parsed.id !== "number") return
|
||||||
|
const result =
|
||||||
|
parsed.method === "fetch"
|
||||||
|
? { status: 200, headers: {}, body: "" }
|
||||||
|
: parsed.method === "server"
|
||||||
|
? { url: "http://127.0.0.1" }
|
||||||
|
: parsed.method === "snapshot"
|
||||||
|
? ""
|
||||||
|
: undefined
|
||||||
|
queueMicrotask(() => {
|
||||||
|
this.onmessage?.(
|
||||||
|
new MessageEvent("message", { data: JSON.stringify({ type: "rpc.result", result, id: parsed.id }) }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
terminate() {}
|
||||||
|
}
|
||||||
|
|
||||||
function setup() {
|
function setup() {
|
||||||
// Intentionally avoid mock.module() here: Bun keeps module overrides in cache
|
// Intentionally avoid mock.module() here: Bun keeps module overrides in cache
|
||||||
// and mock.restore() does not reset mock.module values. If this switches back
|
// and mock.restore() does not reset mock.module values. If this switches back
|
||||||
@@ -25,10 +51,6 @@ function setup() {
|
|||||||
if (input.directory) seen.tui.push(input.directory)
|
if (input.directory) seen.tui.push(input.directory)
|
||||||
throw stop
|
throw stop
|
||||||
})
|
})
|
||||||
spyOn(Rpc, "client").mockImplementation(() => ({
|
|
||||||
call: async () => ({ url: "http://127.0.0.1" }) as never,
|
|
||||||
on: () => () => {},
|
|
||||||
}))
|
|
||||||
spyOn(UI, "error").mockImplementation(() => {})
|
spyOn(UI, "error").mockImplementation(() => {})
|
||||||
spyOn(Timeout, "withTimeout").mockImplementation((input) => input)
|
spyOn(Timeout, "withTimeout").mockImplementation((input) => input)
|
||||||
spyOn(Network, "resolveNetworkOptions").mockResolvedValue({
|
spyOn(Network, "resolveNetworkOptions").mockResolvedValue({
|
||||||
@@ -71,7 +93,6 @@ describe("tui thread", () => {
|
|||||||
|
|
||||||
async function check(project?: string) {
|
async function check(project?: string) {
|
||||||
setup()
|
setup()
|
||||||
const cwd = process.cwd()
|
|
||||||
const pwd = process.env.PWD
|
const pwd = process.env.PWD
|
||||||
const worker = globalThis.Worker
|
const worker = globalThis.Worker
|
||||||
const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY")
|
const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY")
|
||||||
@@ -85,26 +106,26 @@ describe("tui thread", () => {
|
|||||||
configurable: true,
|
configurable: true,
|
||||||
value: true,
|
value: true,
|
||||||
})
|
})
|
||||||
globalThis.Worker = class extends EventTarget {
|
Object.defineProperty(globalThis, "Worker", { configurable: true, value: TestWorker })
|
||||||
onerror = null
|
|
||||||
onmessage = null
|
|
||||||
onmessageerror = null
|
|
||||||
postMessage() {}
|
|
||||||
terminate() {}
|
|
||||||
} as unknown as typeof Worker
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
process.chdir(tmp.path)
|
process.chdir(tmp.path)
|
||||||
process.env.PWD = link
|
process.env.PWD = link
|
||||||
await expect(call(project)).rejects.toBe(stop)
|
let error: unknown
|
||||||
|
try {
|
||||||
|
await call(project)
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught
|
||||||
|
}
|
||||||
|
expect(error).toBe(stop)
|
||||||
expect(seen.tui[0]).toBe(tmp.path)
|
expect(seen.tui[0]).toBe(tmp.path)
|
||||||
} finally {
|
} finally {
|
||||||
process.chdir(cwd)
|
process.chdir(packageRoot)
|
||||||
if (pwd === undefined) delete process.env.PWD
|
if (pwd === undefined) delete process.env.PWD
|
||||||
else process.env.PWD = pwd
|
else process.env.PWD = pwd
|
||||||
if (tty) Object.defineProperty(process.stdin, "isTTY", tty)
|
if (tty) Object.defineProperty(process.stdin, "isTTY", tty)
|
||||||
else delete (process.stdin as { isTTY?: boolean }).isTTY
|
else delete (process.stdin as { isTTY?: boolean }).isTTY
|
||||||
globalThis.Worker = worker
|
Object.defineProperty(globalThis, "Worker", { configurable: true, value: worker })
|
||||||
await fs.rm(link, { recursive: true, force: true }).catch(() => undefined)
|
await fs.rm(link, { recursive: true, force: true }).catch(() => undefined)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,73 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import path from "path"
|
import { Effect, Layer } from "effect"
|
||||||
import { Effect } from "effect"
|
import type { Agent } from "../../src/agent/agent"
|
||||||
import { Agent } from "../../src/agent/agent"
|
import { NamedError } from "@opencode-ai/core/util/error"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Skill } from "../../src/skill"
|
||||||
|
import { Permission } from "../../src/permission"
|
||||||
import { SystemPrompt } from "../../src/session/system"
|
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>) {
|
const skills: Skill.Info[] = [
|
||||||
return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer)))
|
{
|
||||||
|
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", () => {
|
describe("session.system", () => {
|
||||||
test("skills output is sorted by name and stable across calls", async () => {
|
it.effect("skills output is sorted by name and stable across calls", () =>
|
||||||
await using tmp = await tmpdir({
|
Effect.gen(function* () {
|
||||||
git: true,
|
const prompt = yield* SystemPrompt.Service
|
||||||
init: async (dir) => {
|
const first = yield* prompt.skills(build)
|
||||||
for (const [name, description] of [
|
const second = yield* prompt.skills(build)
|
||||||
["zeta-skill", "Zeta skill."],
|
const output = first ?? (yield* Effect.fail(new NamedError.Unknown({ message: "missing skills output" })))
|
||||||
["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}
|
|
||||||
---
|
|
||||||
|
|
||||||
# ${name}
|
expect(first).toBe(second)
|
||||||
`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const home = process.env.OPENCODE_TEST_HOME
|
const alpha = output.indexOf("<name>alpha-skill</name>")
|
||||||
process.env.OPENCODE_TEST_HOME = tmp.path
|
const middle = output.indexOf("<name>middle-skill</name>")
|
||||||
|
const zeta = output.indexOf("<name>zeta-skill</name>")
|
||||||
|
|
||||||
try {
|
expect(alpha).toBeGreaterThan(-1)
|
||||||
await Instance.provide({
|
expect(middle).toBeGreaterThan(alpha)
|
||||||
directory: tmp.path,
|
expect(zeta).toBeGreaterThan(middle)
|
||||||
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.OPENCODE_TEST_HOME = home
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user