Compare commits

..
57 changed files with 1923 additions and 2704 deletions
-48
View File
@@ -342,7 +342,6 @@
"@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@ff-labs/fff-bun": "0.5.2",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/node-server": "1.19.11",
"@hono/node-ws": "1.3.0",
@@ -397,7 +396,6 @@
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"remeda": "catalog:",
"ripgrep": "0.3.1",
"semver": "^7.6.3",
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
@@ -1153,24 +1151,6 @@
"@fastify/rate-limit": ["@fastify/rate-limit@10.3.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q=="],
"@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.5.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6hkXiB5R5n0eDxibVTDFFXKca1PVpVysWOwgFLyXYr6b0BLI5UcuDSz4vZ1fj4eoH+9rgqIqd65YMZ7e7s3J5g=="],
"@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.5.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-I7q1T9Iw/qnCzGT5dY7nxEdqS18vhTbXwWR2LKNPgxUpoGGv7sMe57QhEGAXT8eKvPh8hgZneU8VLt49wmFYRw=="],
"@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-iputHhH4bpOegz+j14JcypuelNPdUwwx3oJavln02jNY1oouPNhC2tNMJKSXgkUgAbuGRn26iXSlUPNwIl0ftQ=="],
"@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5ySavoc0Q5Cr1qKIBj2s8roQEFhCgJM8ndCM6AcPv5tuFirBHJe9iT3OG0x0/u9Mm70MosIYbd3dnbC3QwIWzw=="],
"@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-lRMcoeNlGsqN1jVup95TfWfz74Funn8nuzVVxZUzmZbQOWEDkhfULUB8jpaz9Q7sDq5hqhMa5+zJf29sKuw0hw=="],
"@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-QvNTGvZNKj8h/ZuCY/g+/WMQagK6E8U0Zv3vCbZgUsegvMtGpVNW42w8jiUC21136DnmfF8rG0mz8SsR/99qHw=="],
"@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.5.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mT0A0FsZRgVPDsg4czksGYUuMqXodlrg97ss6jEs60upvHWGTWpgkoiRfm80u2ixVzx0z8yqhbquol1C/mvKGA=="],
"@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.5.2", "", { "os": "win32", "cpu": "x64" }, "sha512-aca29wsv0XcTTwKd0GOpDBZPgMrvs0osqGSqp2cYrznH7/wQZySHYDlXni7b4SY6oqxmuQQYXXwQzAn8CoqLsQ=="],
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.5.2", "", { "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.5.2", "@ff-labs/fff-bin-darwin-x64": "0.5.2", "@ff-labs/fff-bin-linux-arm64-gnu": "0.5.2", "@ff-labs/fff-bin-linux-arm64-musl": "0.5.2", "@ff-labs/fff-bin-linux-x64-gnu": "0.5.2", "@ff-labs/fff-bin-linux-x64-musl": "0.5.2", "@ff-labs/fff-bin-win32-arm64": "0.5.2", "@ff-labs/fff-bin-win32-x64": "0.5.2" }, "peerDependencies": { "bun": ">=1.0.0" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "fff-demo": "examples/search.ts", "fff-grep": "examples/grep.ts" } }, "sha512-puwgVLi7RfjzqB4biVB5ZCvtLbTf34yEorJ3PhBpwaM0pB8hFM+9VRXdNhGF+I3F0LU6twuNsS43AomgyrdDvQ=="],
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
@@ -1591,30 +1571,6 @@
"@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="],
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b6CQgT28Jx7uDwMTcGo7WFqUd1+wWTdp8XyPi/4LRcL/R4deKT7cLx/Q2ZCWAiK6ZU7yexoCaIaKun6azjRLVA=="],
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-//6W21c+GinAMMmxD2hFrFmJH+ZlEwJYbLzAGqp0mLFTli9y74RMtDgI2n9pCupXSpU1Kr1sSylVW9yNbAG9Xg=="],
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-9jKJNOc9ID3BxPBPR4r1Mp1Wqde89Twi5zo2LoEMLMKbqpvEM/WUGdJ0Vv7OX1QPEqVblFO6NMky5yY7rjDI2w=="],
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-eTru6tk3K4Ya3SSkUqq/LbdEjwPqLlfINmIhRORrCExBdB1tQbk+WYYflaymO61fkrjnMAjmLTGqk/K37RMIGA=="],
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-HWIwFzm5fALd9Lli0CgaKb6xOGqODYyHpUTgkn/IHHuS/f3XDCu71+GgkyvfgCYbPoBSgBOfp5TzhRehPcgxow=="],
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.12", "", { "os": "linux", "cpu": "x64" }, "sha512-H75bcEn46lMDxd+P+R6Q/jlIKl/YO0ZXaalSyWhQHr7qNmFhQt3rOHurFoCxuwQeqFoToh0JpWVyMVzByZqgBQ=="],
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.12", "", { "os": "linux", "cpu": "x64" }, "sha512-0y+lUiQsPvSGsyM/10KtxhVAQ20p6/D+vj01l6vo9gHpYUpyc1L9pSgaPa7SC9TuaiGASlM3Cb62bmSKW0E/3Q=="],
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Zb7T3JxWlArSe44ATO5mtjLCBCt7kenWPl9CYD+zeqq9kHswMv8Cd3h/9uzdv2PA4Flrq57J5XBSuRdStTCXCw=="],
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.12", "", { "os": "linux", "cpu": "x64" }, "sha512-jdsnuFD3H0l4AHtf1nInRHYWIMTWqok0aW8WysjzN5Isn6rBTBGK/ZWX6XjdTgDgcuVbVOYHiLUHHrvT9N6psA=="],
"@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.3.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-Oq0FIcCgL3JWf/4qRuxI5fxsOGyWJ1j904PDx/1TxxSCWWAu0Hh2o8ck4TcaPVv/3BMc1k6UxqQQKBrdP7a+qQ=="],
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.12", "", { "os": "win32", "cpu": "x64" }, "sha512-veSntY7pDLDh4XmxZMwTqxfoEVp0BDdeqCBoWL46/TigtniPtDFSTIWBxa6l/RcGzklUA/uqLqmsK/9cBZAm8Q=="],
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.12", "", { "os": "win32", "cpu": "x64" }, "sha512-rV21md7QWnu3r/shev7IFMh6hX8BJHwofxESAofUT4yH866oCIbcNbzp6+fxrj4oGD8uisP6WoaTCboijv9yYg=="],
"@oxc-minify/binding-android-arm64": ["@oxc-minify/binding-android-arm64@0.96.0", "", { "os": "android", "cpu": "arm64" }, "sha512-lzeIEMu/v6Y+La5JSesq4hvyKtKBq84cgQpKYTYM/yGuNk2tfd5Ha31hnC+mTh48lp/5vZH+WBfjVUjjINCfug=="],
"@oxc-minify/binding-darwin-arm64": ["@oxc-minify/binding-darwin-arm64@0.96.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-i0LkJAUXb4BeBFrJQbMKQPoxf8+cFEffDyLSb7NEzzKuPcH8qrVsnEItoOzeAdYam8Sr6qCHVwmBNEQzl7PWpw=="],
@@ -2627,8 +2583,6 @@
"builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
"bun": ["bun@1.3.12", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.12", "@oven/bun-darwin-x64": "1.3.12", "@oven/bun-darwin-x64-baseline": "1.3.12", "@oven/bun-linux-aarch64": "1.3.12", "@oven/bun-linux-aarch64-musl": "1.3.12", "@oven/bun-linux-x64": "1.3.12", "@oven/bun-linux-x64-baseline": "1.3.12", "@oven/bun-linux-x64-musl": "1.3.12", "@oven/bun-linux-x64-musl-baseline": "1.3.12", "@oven/bun-windows-aarch64": "1.3.12", "@oven/bun-windows-x64": "1.3.12", "@oven/bun-windows-x64-baseline": "1.3.12" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-KLwDUqs5WIny/94F4xZ4QfaAE6YWyjR+s79pt/ItQhk2CG+PJQ5xL6VuOWhiyN2eP3fryZK95vog9CTLCaYV2Q=="],
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
@@ -4391,8 +4345,6 @@
"rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
"ripgrep": ["ripgrep@0.3.1", "", { "bin": { "rg": "lib/rg.mjs", "ripgrep": "lib/rg.mjs" } }, "sha512-6bDtNIBh1qPviVIU685/4uv0Ap5t8eS4wiJhy/tR2LdIeIey9CVasENlGS+ul3HnTmGANIp7AjnfsztsRmALfQ=="],
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
"rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-StHGWSONcykyo5kk3aEdfTu4BcwPEA5PbtJN2W2rIhs=",
"aarch64-linux": "sha256-W0nu5L0W4YiGx15nmzi+JInV0Bh04JOPQxp0lRRgMWQ=",
"aarch64-darwin": "sha256-k1hYWg3s5eK7qHFJPBvJtt1IllRL969vNUdXe8ITR4Y=",
"x86_64-darwin": "sha256-Mpsgkm5uAREitA9zeG9XtQ8hUL+umLJLENUww4mhWes="
"x86_64-linux": "sha256-fiMi8VxyMhNTaZf0ButrMEwT/ZmfeEg1T3c6HwUz8p4=",
"aarch64-linux": "sha256-1Mzjijq/INZGGEm4EerYN3hu1VxiQ8wuGg6t+XPDf6w=",
"aarch64-darwin": "sha256-3SH8Q2kK/F2kM29FmFUMR1aA23rSei+mPJliRIGfvCM=",
"x86_64-darwin": "sha256-RPsyoNXn84K93gunRFLsBvkZIQilfmUXdwkeieQjbd8="
}
}
-7
View File
@@ -28,11 +28,6 @@
"node": "./src/storage/db.node.ts",
"default": "./src/storage/db.bun.ts"
},
"#fff": {
"bun": "./src/file/fff.bun.ts",
"node": "./src/file/fff.node.ts",
"default": "./src/file/fff.bun.ts"
},
"#pty": {
"bun": "./src/pty/pty.bun.ts",
"node": "./src/pty/pty.node.ts",
@@ -104,7 +99,6 @@
"@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@ff-labs/fff-bun": "0.5.2",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/node-server": "1.19.11",
"@hono/node-ws": "1.3.0",
@@ -159,7 +153,6 @@
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"remeda": "catalog:",
"ripgrep": "0.3.1",
"semver": "^7.6.3",
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
+1 -12
View File
@@ -187,7 +187,6 @@ for (const item of targets) {
const rootPath = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
const parserWorker = fs.realpathSync(fs.existsSync(localPath) ? localPath : rootPath)
const workerPath = "./src/cli/cmd/tui/worker.ts"
const rgPath = "./src/file/ripgrep.worker.ts"
// Use platform-specific bunfs root path based on target OS
const bunfsRoot = item.os === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/"
@@ -198,9 +197,6 @@ for (const item of targets) {
tsconfig: "./tsconfig.json",
plugins: [plugin],
external: ["node-gyp"],
format: "esm",
minify: true,
splitting: true,
compile: {
autoloadBunfig: false,
autoloadDotenv: false,
@@ -214,19 +210,12 @@ for (const item of targets) {
files: {
...(embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {}),
},
entrypoints: [
"./src/index.ts",
parserWorker,
workerPath,
rgPath,
...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : []),
],
entrypoints: ["./src/index.ts", parserWorker, workerPath, ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : [])],
define: {
OPENCODE_VERSION: `'${Script.version}'`,
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath,
OPENCODE_WORKER_PATH: workerPath,
OPENCODE_RIPGREP_WORKER_PATH: rgPath,
OPENCODE_CHANNEL: `'${Script.channel}'`,
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "",
},
+32 -25
View File
@@ -33,31 +33,38 @@ const seed = async () => {
}),
)
const session = await Session.create({ title })
const messageID = MessageID.ascending()
const partID = PartID.ascending()
const message = {
id: messageID,
sessionID: session.id,
role: "user" as const,
time: { created: now },
agent: "build",
model: {
providerID: ProviderID.make(providerID),
modelID: ModelID.make(modelID),
},
}
const part = {
id: partID,
sessionID: session.id,
messageID,
type: "text" as const,
text,
time: { start: now },
}
await Session.updateMessage(message)
await Session.updatePart(part)
await Project.update({ projectID: Instance.project.id, name: "E2E Project" })
await AppRuntime.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
const result = yield* session.create({ title })
const messageID = MessageID.ascending()
const partID = PartID.ascending()
const message = {
id: messageID,
sessionID: result.id,
role: "user" as const,
time: { created: now },
agent: "build",
model: {
providerID: ProviderID.make(providerID),
modelID: ModelID.make(modelID),
},
}
const part = {
id: partID,
sessionID: result.id,
messageID,
type: "text" as const,
text,
time: { start: now },
}
yield* session.updateMessage(message)
yield* session.updatePart(part)
}),
)
await AppRuntime.runPromise(
Project.Service.use((svc) => svc.update({ projectID: Instance.project.id, name: "E2E Project" })),
)
},
})
} finally {
+43 -39
View File
@@ -123,45 +123,49 @@ function parseToolParams(input?: string) {
}
async function createToolContext(agent: Agent.Info) {
const session = await Session.create({ title: `Debug tool run (${agent.name})` })
const messageID = MessageID.ascending()
const model =
agent.model ??
(await AppRuntime.runPromise(
Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.defaultModel()
}),
))
const now = Date.now()
const message: MessageV2.Assistant = {
id: messageID,
sessionID: session.id,
role: "assistant",
time: {
created: now,
},
parentID: messageID,
modelID: model.modelID,
providerID: model.providerID,
mode: "debug",
agent: agent.name,
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
cost: 0,
tokens: {
input: 0,
output: 0,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
}
await Session.updateMessage(message)
const { session, messageID } = await AppRuntime.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
const result = yield* session.create({ title: `Debug tool run (${agent.name})` })
const messageID = MessageID.ascending()
const model = agent.model
? agent.model
: yield* Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.defaultModel()
})
const now = Date.now()
const message: MessageV2.Assistant = {
id: messageID,
sessionID: result.id,
role: "assistant",
time: {
created: now,
},
parentID: messageID,
modelID: model.modelID,
providerID: model.providerID,
mode: "debug",
agent: agent.name,
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
cost: 0,
tokens: {
input: 0,
output: 0,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
}
yield* session.updateMessage(message)
return { session: result, messageID }
}),
)
const ruleset = Permission.merge(agent.permission, session.permission ?? [])
+2 -2
View File
@@ -1,10 +1,10 @@
import { EOL } from "os"
import { Effect } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { Search } from "@/file/search"
import { File } from "../../../file"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { Ripgrep } from "@/file/ripgrep"
const FileSearchCommand = cmd({
command: "search <query>",
@@ -95,7 +95,7 @@ const FileTreeCommand = cmd({
default: process.cwd(),
}),
async handler(args) {
const files = await Search.tree({ cwd: args.dir, limit: 200 })
const files = await Ripgrep.tree({ cwd: args.dir, limit: 200 })
console.log(JSON.stringify(files, null, 2))
},
})
@@ -1,6 +1,6 @@
import { EOL } from "os"
import { AppRuntime } from "../../../effect/app-runtime"
import { Search } from "../../../file/search"
import { Ripgrep } from "../../../file/ripgrep"
import { Instance } from "../../../project/instance"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
@@ -21,7 +21,7 @@ const TreeCommand = cmd({
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
process.stdout.write((await Search.tree({ cwd: Instance.directory, limit: args.limit })) + EOL)
process.stdout.write((await Ripgrep.tree({ cwd: Instance.directory, limit: args.limit })) + EOL)
})
},
})
@@ -46,7 +46,7 @@ const FilesCommand = cmd({
async handler(args) {
await bootstrap(process.cwd(), async () => {
const files: string[] = []
for await (const file of await Search.files({
for await (const file of Ripgrep.files({
cwd: Instance.directory,
glob: args.glob ? [args.glob] : undefined,
})) {
@@ -79,7 +79,7 @@ const SearchCommand = cmd({
async handler(args) {
await bootstrap(process.cwd(), async () => {
const results = await AppRuntime.runPromise(
Search.Service.use((svc) =>
Ripgrep.Service.use((svc) =>
svc.search({
cwd: Instance.directory,
pattern: args.pattern,
+12 -2
View File
@@ -6,6 +6,8 @@ import { bootstrap } from "../bootstrap"
import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { EOL } from "os"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
export const ExportCommand = cmd({
command: "export [sessionID]",
@@ -67,8 +69,16 @@ export const ExportCommand = cmd({
}
try {
const sessionInfo = await Session.get(sessionID!)
const messages = await Session.messages({ sessionID: sessionInfo.id })
const { sessionInfo, messages } = await AppRuntime.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
const sessionInfo = yield* session.get(sessionID!)
return {
sessionInfo,
messages: yield* session.messages({ sessionID: sessionInfo.id }),
}
}),
)
const exportData = {
info: sessionInfo,
+89 -94
View File
@@ -33,6 +33,7 @@ import { AppRuntime } from "@/effect/app-runtime"
import { Git } from "@/git"
import { setTimeout as sleep } from "node:timers/promises"
import { Process } from "@/util/process"
import { Effect } from "effect"
type GitHubAuthor = {
login: string
@@ -551,20 +552,24 @@ export const GithubRunCommand = cmd({
// Setup opencode session
const repoData = await fetchRepo()
session = await Session.create({
permission: [
{
permission: "question",
action: "deny",
pattern: "*",
},
],
})
session = await AppRuntime.runPromise(
Session.Service.use((svc) =>
svc.create({
permission: [
{
permission: "question",
action: "deny",
pattern: "*",
},
],
}),
),
)
subscribeSessionEvents()
shareId = await (async () => {
if (share === false) return
if (!share && repoData.data.private) return
await SessionShare.share(session.id)
await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.share(session.id)))
return session.id.slice(-8)
})()
console.log("opencode session", session.id)
@@ -937,96 +942,86 @@ export const GithubRunCommand = cmd({
async function chat(message: string, files: PromptFiles = []) {
console.log("Sending message to opencode...")
const result = await SessionPrompt.prompt({
sessionID: session.id,
messageID: MessageID.ascending(),
variant,
model: {
providerID,
modelID,
},
// agent is omitted - server will use default_agent from config or fall back to "build"
parts: [
{
id: PartID.ascending(),
type: "text",
text: message,
},
...files.flatMap((f) => [
{
id: PartID.ascending(),
type: "file" as const,
mime: f.mime,
url: `data:${f.mime};base64,${f.content}`,
filename: f.filename,
source: {
type: "file" as const,
text: {
value: f.replacement,
start: f.start,
end: f.end,
},
path: f.filename,
},
return AppRuntime.runPromise(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const result = yield* prompt.prompt({
sessionID: session.id,
messageID: MessageID.ascending(),
variant,
model: {
providerID,
modelID,
},
]),
],
})
// agent is omitted - server will use default_agent from config or fall back to "build"
parts: [
{
id: PartID.ascending(),
type: "text",
text: message,
},
...files.flatMap((f) => [
{
id: PartID.ascending(),
type: "file" as const,
mime: f.mime,
url: `data:${f.mime};base64,${f.content}`,
filename: f.filename,
source: {
type: "file" as const,
text: {
value: f.replacement,
start: f.start,
end: f.end,
},
path: f.filename,
},
},
]),
],
})
// result should always be assistant just satisfying type checker
if (result.info.role === "assistant" && result.info.error) {
const err = result.info.error
console.error("Agent error:", err)
if (result.info.role === "assistant" && result.info.error) {
const err = result.info.error
console.error("Agent error:", err)
if (err.name === "ContextOverflowError") throw new Error(formatPromptTooLargeError(files))
throw new Error(`${err.name}: ${err.data?.message || ""}`)
}
if (err.name === "ContextOverflowError") {
throw new Error(formatPromptTooLargeError(files))
}
const text = extractResponseText(result.parts)
if (text) return text
const errorMsg = err.data?.message || ""
throw new Error(`${err.name}: ${errorMsg}`)
}
console.log("Requesting summary from agent...")
const summary = yield* prompt.prompt({
sessionID: session.id,
messageID: MessageID.ascending(),
variant,
model: {
providerID,
modelID,
},
tools: { "*": false },
parts: [
{
id: PartID.ascending(),
type: "text",
text: "Summarize the actions (tool calls & reasoning) you did for the user in 1-2 sentences.",
},
],
})
const text = extractResponseText(result.parts)
if (text) return text
if (summary.info.role === "assistant" && summary.info.error) {
const err = summary.info.error
console.error("Summary agent error:", err)
if (err.name === "ContextOverflowError") throw new Error(formatPromptTooLargeError(files))
throw new Error(`${err.name}: ${err.data?.message || ""}`)
}
// No text part (tool-only or reasoning-only) - ask agent to summarize
console.log("Requesting summary from agent...")
const summary = await SessionPrompt.prompt({
sessionID: session.id,
messageID: MessageID.ascending(),
variant,
model: {
providerID,
modelID,
},
tools: { "*": false }, // Disable all tools to force text response
parts: [
{
id: PartID.ascending(),
type: "text",
text: "Summarize the actions (tool calls & reasoning) you did for the user in 1-2 sentences.",
},
],
})
if (summary.info.role === "assistant" && summary.info.error) {
const err = summary.info.error
console.error("Summary agent error:", err)
if (err.name === "ContextOverflowError") {
throw new Error(formatPromptTooLargeError(files))
}
const errorMsg = err.data?.message || ""
throw new Error(`${err.name}: ${errorMsg}`)
}
const summaryText = extractResponseText(summary.parts)
if (!summaryText) {
throw new Error("Failed to get summary from agent")
}
return summaryText
const summaryText = extractResponseText(summary.parts)
if (!summaryText) throw new Error("Failed to get summary from agent")
return summaryText
}),
)
}
async function getOidcToken() {
+3 -2
View File
@@ -11,6 +11,7 @@ import { Process } from "../../util/process"
import { EOL } from "os"
import path from "path"
import { which } from "../../util/which"
import { AppRuntime } from "@/effect/app-runtime"
function pagerCmd(): string[] {
const lessOptions = ["-R", "-S"]
@@ -60,12 +61,12 @@ export const SessionDeleteCommand = cmd({
await bootstrap(process.cwd(), async () => {
const sessionID = SessionID.make(args.sessionID)
try {
await Session.get(sessionID)
await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
} catch {
UI.error(`Session not found: ${args.sessionID}`)
process.exit(1)
}
await Session.remove(sessionID)
await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID)))
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
})
},
+4 -1
View File
@@ -6,6 +6,7 @@ import { Database } from "../../storage/db"
import { SessionTable } from "../../session/session.sql"
import { Project } from "../../project/project"
import { Instance } from "../../project/instance"
import { AppRuntime } from "@/effect/app-runtime"
interface SessionStats {
totalSessions: number
@@ -167,7 +168,9 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
const batch = filteredSessions.slice(i, i + BATCH_SIZE)
const batchPromises = batch.map(async (session) => {
const messages = await Session.messages({ sessionID: session.id })
const messages = await AppRuntime.runPromise(
Session.Service.use((svc) => svc.messages({ sessionID: session.id })),
)
let sessionCost = 0
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
+2 -2
View File
@@ -8,7 +8,7 @@ import { Auth } from "@/auth"
import { Account } from "@/account"
import { Config } from "@/config/config"
import { Git } from "@/git"
import { Search } from "@/file/search"
import { Ripgrep } from "@/file/ripgrep"
import { FileTime } from "@/file/time"
import { File } from "@/file"
import { FileWatcher } from "@/file/watcher"
@@ -56,7 +56,7 @@ export const AppLayer = Layer.mergeAll(
Account.defaultLayer,
Config.defaultLayer,
Git.defaultLayer,
Search.defaultLayer,
Ripgrep.defaultLayer,
FileTime.defaultLayer,
File.defaultLayer,
FileWatcher.defaultLayer,
-89
View File
@@ -1,89 +0,0 @@
import {
FileFinder,
type FileItem,
type GrepCursor,
type GrepMatch,
type GrepResult,
type SearchResult,
} from "@ff-labs/fff-bun"
export namespace Fff {
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
export interface Init {
basePath: string
frecencyDbPath?: string
historyDbPath?: string
aiMode?: boolean
}
export interface Search {
items: FileItem[]
scores: SearchResult["scores"]
totalMatched: number
totalFiles: number
}
export type File = FileItem
export type Cursor = GrepCursor | null
export type Hit = GrepMatch
export interface Grep {
items: GrepResult["items"]
totalMatched: number
totalFilesSearched: number
totalFiles: number
filteredFileCount: number
nextCursor: Cursor
regexFallbackError?: string
}
export interface Picker {
destroy(): void
waitForScan(timeout?: number): Result<boolean>
refreshGitStatus(): Result<number>
fileSearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<Search>
grep(
query: string,
opts?: {
mode?: "plain" | "regex" | "fuzzy"
maxMatchesPerFile?: number
timeBudgetMs?: number
beforeContext?: number
afterContext?: number
cursor?: Cursor
},
): Result<Grep>
trackQuery(query: string, file: string): Result<boolean>
getHistoricalQuery(offset: number): Result<string | null>
}
export function available() {
return FileFinder.isAvailable()
}
export function create(opts: Init): Result<Picker> {
const made = FileFinder.create(opts)
if (!made.ok) return made
const pick = made.value
return {
ok: true,
value: {
destroy: () => pick.destroy(),
waitForScan: (timeout) => pick.waitForScan(timeout),
refreshGitStatus: () => pick.refreshGitStatus(),
fileSearch: (query, next) => pick.fileSearch(query, next),
grep: (query, next) => pick.grep(query, next),
trackQuery: (query, file) => pick.trackQuery(query, file),
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
},
}
}
}
-82
View File
@@ -1,82 +0,0 @@
export namespace Fff {
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
export interface Init {
basePath: string
frecencyDbPath?: string
historyDbPath?: string
aiMode?: boolean
}
export interface File {
path: string
relativePath: string
fileName: string
}
export interface Search {
items: File[]
scores: unknown[]
totalMatched: number
totalFiles: number
}
export type Cursor = null
export interface Hit {
path: string
relativePath: string
fileName: string
lineNumber: number
byteOffset: number
lineContent: string
matchRanges: [number, number][]
contextBefore?: string[]
contextAfter?: string[]
}
export interface Grep {
items: Hit[]
totalMatched: number
totalFilesSearched: number
totalFiles: number
filteredFileCount: number
nextCursor: Cursor
regexFallbackError?: string
}
export interface Picker {
destroy(): void
waitForScan(timeout?: number): Result<boolean>
refreshGitStatus(): Result<number>
fileSearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<Search>
grep(
query: string,
opts?: {
mode?: "plain" | "regex" | "fuzzy"
maxMatchesPerFile?: number
timeBudgetMs?: number
beforeContext?: number
afterContext?: number
cursor?: Cursor
},
): Result<Grep>
trackQuery(query: string, file: string): Result<boolean>
getHistoricalQuery(offset: number): Result<string | null>
}
export function available() {
return false
}
export function create(): Result<Picker> {
return { ok: false, error: "fff unavailable" }
}
}
+6 -52
View File
@@ -1,10 +1,8 @@
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { Git } from "@/git"
import { Effect, Layer, Context } from "effect"
import * as Stream from "effect/Stream"
import { formatPatch, structuredPatch } from "diff"
import fuzzysort from "fuzzysort"
import ignore from "ignore"
@@ -14,7 +12,7 @@ import { Global } from "../global"
import { Instance } from "../project/instance"
import { Log } from "../util/log"
import { Protected } from "./protected"
import { Search } from "./search"
import { Ripgrep } from "./ripgrep"
export namespace File {
export const Info = z
@@ -344,7 +342,6 @@ export namespace File {
Service,
Effect.gen(function* () {
const appFs = yield* AppFileSystem.Service
const searchSvc = yield* Search.Service
const git = yield* Git.Service
const state = yield* InstanceState.make<State>(
@@ -384,10 +381,7 @@ export namespace File {
next.dirs = Array.from(dirs).toSorted()
} else {
const files = yield* searchSvc.files({ cwd: Instance.directory }).pipe(
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
const files = yield* Effect.promise(() => Array.fromAsync(Ripgrep.files({ cwd: Instance.directory })))
const seen = new Set<string>()
for (const file of files) {
next.files.push(file)
@@ -512,8 +506,6 @@ export namespace File {
if (!Instance.containsPath(full)) throw new Error("Access denied: path escapes project directory")
yield* searchSvc.open({ cwd: Instance.directory, file }).pipe(Effect.ignore)
if (isImageByExtension(file)) {
const exists = yield* appFs.existsSafe(full)
if (exists) {
@@ -619,26 +611,14 @@ export namespace File {
dirs?: boolean
type?: "file" | "directory"
}) {
yield* ensure()
const { cache } = yield* InstanceState.get(state)
const query = input.query.trim()
const limit = input.limit ?? 100
const kind = input.type ?? (input.dirs === false ? "file" : "all")
log.info("search", { query, kind })
if (query && kind === "file") {
const files = yield* searchSvc.file({
cwd: Instance.directory,
query,
limit,
})
if (files.length) {
log.info("search", { query, kind, results: files.length, mode: "fff" })
return files
}
}
yield* ensure()
const { cache } = yield* InstanceState.get(state)
const preferHidden = query.startsWith(".") || query.includes("/.")
if (!query) {
@@ -662,31 +642,5 @@ export namespace File {
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Search.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Git.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export function init() {
return runPromise((svc) => svc.init())
}
export async function status() {
return runPromise((svc) => svc.status())
}
export async function read(file: string): Promise<Content> {
return runPromise((svc) => svc.read(file))
}
export async function list(dir?: string) {
return runPromise((svc) => svc.list(dir))
}
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
return runPromise((svc) => svc.search(input))
}
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
}
+402 -461
View File
@@ -1,16 +1,28 @@
import fs from "fs/promises"
// Ripgrep utility functions
import path from "path"
import { fileURLToPath } from "url"
import { Global } from "../global"
import fs from "fs/promises"
import z from "zod"
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
import { ripgrep } from "ripgrep"
import { makeRuntime } from "@/effect/run-service"
import { Filesystem } from "@/util/filesystem"
import { Effect, Layer, Context, Schema } from "effect"
import * as Stream from "effect/Stream"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import type { PlatformError } from "effect/PlatformError"
import { NamedError } from "@opencode-ai/util/error"
import { lazy } from "../util/lazy"
import { Filesystem } from "../util/filesystem"
import { AppFileSystem } from "../filesystem"
import { Process } from "../util/process"
import { which } from "../util/which"
import { text } from "node:stream/consumers"
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
import { Log } from "@/util/log"
export namespace Ripgrep {
const log = Log.create({ service: "ripgrep" })
const Stats = z.object({
elapsed: z.object({
secs: z.number(),
@@ -82,508 +94,437 @@ export namespace Ripgrep {
const Result = z.union([Begin, Match, End, Summary])
const Hit = Schema.Struct({
type: Schema.Literal("match"),
data: Schema.Struct({
path: Schema.Struct({
text: Schema.String,
}),
lines: Schema.Struct({
text: Schema.String,
}),
line_number: Schema.Number,
absolute_offset: Schema.Number,
submatches: Schema.mutable(
Schema.Array(
Schema.Struct({
match: Schema.Struct({
text: Schema.String,
}),
start: Schema.Number,
end: Schema.Number,
}),
),
),
}),
})
const Row = Schema.Union([
Schema.Struct({ type: Schema.Literal("begin"), data: Schema.Unknown }),
Hit,
Schema.Struct({ type: Schema.Literal("end"), data: Schema.Unknown }),
Schema.Struct({ type: Schema.Literal("summary"), data: Schema.Unknown }),
])
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Row))
export type Result = z.infer<typeof Result>
export type Match = z.infer<typeof Match>
export type Item = Match["data"]
export type Begin = z.infer<typeof Begin>
export type End = z.infer<typeof End>
export type Summary = z.infer<typeof Summary>
export type Row = Match["data"]
const PLATFORM = {
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
"arm64-linux": {
platform: "aarch64-unknown-linux-gnu",
extension: "tar.gz",
},
"x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
"x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
"arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" },
"x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
} as const
export interface SearchResult {
items: Item[]
partial: boolean
export const ExtractionFailedError = NamedError.create(
"RipgrepExtractionFailedError",
z.object({
filepath: z.string(),
stderr: z.string(),
}),
)
export const UnsupportedPlatformError = NamedError.create(
"RipgrepUnsupportedPlatformError",
z.object({
platform: z.string(),
}),
)
export const DownloadFailedError = NamedError.create(
"RipgrepDownloadFailedError",
z.object({
url: z.string(),
status: z.number(),
}),
)
const state = lazy(async () => {
const system = which("rg")
if (system) {
const stat = await fs.stat(system).catch(() => undefined)
if (stat?.isFile()) return { filepath: system }
log.warn("bun.which returned invalid rg path", { filepath: system })
}
const filepath = path.join(Global.Path.bin, "rg" + (process.platform === "win32" ? ".exe" : ""))
if (!(await Filesystem.exists(filepath))) {
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
const config = PLATFORM[platformKey]
if (!config) throw new UnsupportedPlatformError({ platform: platformKey })
const version = "14.1.1"
const filename = `ripgrep-${version}-${config.platform}.${config.extension}`
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`
const response = await fetch(url)
if (!response.ok) throw new DownloadFailedError({ url, status: response.status })
const arrayBuffer = await response.arrayBuffer()
const archivePath = path.join(Global.Path.bin, filename)
await Filesystem.write(archivePath, Buffer.from(arrayBuffer))
if (config.extension === "tar.gz") {
const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
if (platformKey.endsWith("-darwin")) args.push("--include=*/rg")
if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg")
const proc = Process.spawn(args, {
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "pipe",
})
const exit = await proc.exited
if (exit !== 0) {
const stderr = proc.stderr ? await text(proc.stderr) : ""
throw new ExtractionFailedError({
filepath,
stderr,
})
}
}
if (config.extension === "zip") {
const zipFileReader = new ZipReader(new BlobReader(new Blob([arrayBuffer])))
const entries = await zipFileReader.getEntries()
let rgEntry: any
for (const entry of entries) {
if (entry.filename.endsWith("rg.exe")) {
rgEntry = entry
break
}
}
if (!rgEntry) {
throw new ExtractionFailedError({
filepath: archivePath,
stderr: "rg.exe not found in zip archive",
})
}
const rgBlob = await rgEntry.getData(new BlobWriter())
if (!rgBlob) {
throw new ExtractionFailedError({
filepath: archivePath,
stderr: "Failed to extract rg.exe from zip archive",
})
}
await Filesystem.write(filepath, Buffer.from(await rgBlob.arrayBuffer()))
await zipFileReader.close()
}
await fs.unlink(archivePath)
if (!platformKey.endsWith("-win32")) await fs.chmod(filepath, 0o755)
}
return {
filepath,
}
})
export async function filepath() {
const { filepath } = await state()
return filepath
}
export interface FilesInput {
export async function* files(input: {
cwd: string
glob?: string[]
hidden?: boolean
follow?: boolean
maxDepth?: number
signal?: AbortSignal
}
}) {
input.signal?.throwIfAborted()
export interface SearchInput {
cwd: string
pattern: string
glob?: string[]
limit?: number
follow?: boolean
file?: string[]
signal?: AbortSignal
}
export interface TreeInput {
cwd: string
limit?: number
signal?: AbortSignal
}
export interface Interface {
readonly files: (input: FilesInput) => Stream.Stream<string, Error>
readonly tree: (input: TreeInput) => Effect.Effect<string, Error>
readonly search: (input: SearchInput) => Effect.Effect<SearchResult, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
type Run = { kind: "files" | "search"; cwd: string; args: string[] }
type WorkerResult = {
type: "result"
code: number
stdout: string
stderr: string
}
type WorkerLine = {
type: "line"
line: string
}
type WorkerDone = {
type: "done"
code: number
stderr: string
}
type WorkerError = {
type: "error"
error: {
message: string
name?: string
stack?: string
}
}
function env() {
const env = Object.fromEntries(
Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined),
)
delete env.RIPGREP_CONFIG_PATH
return env
}
function text(input: unknown) {
if (typeof input === "string") return input
if (input instanceof ArrayBuffer) return Buffer.from(input).toString()
if (ArrayBuffer.isView(input)) return Buffer.from(input.buffer, input.byteOffset, input.byteLength).toString()
return String(input)
}
function toError(input: unknown) {
if (input instanceof Error) return input
if (typeof input === "string") return new Error(input)
return new Error(String(input))
}
function abort(signal?: AbortSignal) {
const err = signal?.reason
if (err instanceof Error) return err
const out = new Error("Aborted")
out.name = "AbortError"
return out
}
function error(stderr: string, code: number) {
const err = new Error(stderr.trim() || `ripgrep failed with code ${code}`)
err.name = "RipgrepError"
return err
}
function clean(file: string) {
return path.normalize(file.replace(/^\.[\\/]/, ""))
}
function row(data: Row): Row {
return {
...data,
path: {
...data.path,
text: clean(data.path.text),
},
}
}
function opts(cwd: string) {
return {
env: env(),
preopens: { ".": cwd },
}
}
function check(cwd: string) {
return Effect.tryPromise({
try: () => fs.stat(cwd).catch(() => undefined),
catch: toError,
}).pipe(
Effect.flatMap((stat) =>
stat?.isDirectory()
? Effect.void
: Effect.fail(
Object.assign(new Error(`No such file or directory: '${cwd}'`), {
code: "ENOENT",
errno: -2,
path: cwd,
}),
),
),
)
}
function filesArgs(input: FilesInput) {
const args = ["--files", "--glob=!.git/*"]
const args = [await filepath(), "--files", "--glob=!.git/*"]
if (input.follow) args.push("--follow")
if (input.hidden !== false) args.push("--hidden")
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
if (input.glob) {
for (const glob of input.glob) {
args.push(`--glob=${glob}`)
for (const g of input.glob) {
args.push(`--glob=${g}`)
}
}
args.push(".")
return args
}
function searchArgs(input: SearchInput) {
const args = ["--json", "--hidden", "--glob=!.git/*", "--no-messages"]
if (input.follow) args.push("--follow")
if (input.glob) {
for (const glob of input.glob) {
args.push(`--glob=${glob}`)
// Guard against invalid cwd to provide a consistent ENOENT error.
if (!(await fs.stat(input.cwd).catch(() => undefined))?.isDirectory()) {
throw Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
code: "ENOENT",
errno: -2,
path: input.cwd,
})
}
const proc = Process.spawn(args, {
cwd: input.cwd,
stdout: "pipe",
stderr: "ignore",
abort: input.signal,
})
if (!proc.stdout) {
throw new Error("Process output not available")
}
let buffer = ""
const stream = proc.stdout as AsyncIterable<Buffer | string>
for await (const chunk of stream) {
input.signal?.throwIfAborted()
buffer += typeof chunk === "string" ? chunk : chunk.toString()
// Handle both Unix (\n) and Windows (\r\n) line endings
const lines = buffer.split(/\r?\n/)
buffer = lines.pop() || ""
for (const line of lines) {
if (line) yield line
}
}
if (input.limit) args.push(`--max-count=${input.limit}`)
args.push("--", input.pattern, ...(input.file ?? ["."]))
return args
if (buffer) yield buffer
await proc.exited
input.signal?.throwIfAborted()
}
function parse(stdout: string) {
return stdout
.trim()
.split(/\r?\n/)
.filter(Boolean)
.map((line) => Result.parse(JSON.parse(line)))
.flatMap((item) => (item.type === "match" ? [row(item.data)] : []))
export interface Interface {
readonly files: (input: {
cwd: string
glob?: string[]
hidden?: boolean
follow?: boolean
maxDepth?: number
}) => Stream.Stream<string, PlatformError>
readonly search: (input: {
cwd: string
pattern: string
glob?: string[]
limit?: number
follow?: boolean
file?: string[]
}) => Effect.Effect<{ items: Item[]; partial: boolean }, PlatformError | Error>
}
declare const OPENCODE_RIPGREP_WORKER_PATH: string
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
function target(): Effect.Effect<string | URL, Error> {
if (typeof OPENCODE_RIPGREP_WORKER_PATH !== "undefined") {
return Effect.succeed(OPENCODE_RIPGREP_WORKER_PATH)
}
const js = new URL("./ripgrep.worker.js", import.meta.url)
return Effect.tryPromise({
try: () => Filesystem.exists(fileURLToPath(js)),
catch: toError,
}).pipe(Effect.map((exists) => (exists ? js : new URL("./ripgrep.worker.ts", import.meta.url))))
}
function worker() {
return target().pipe(Effect.flatMap((file) => Effect.sync(() => new Worker(file, { env: env() }))))
}
function drain(buf: string, chunk: unknown, push: (line: string) => void) {
const lines = (buf + text(chunk)).split(/\r?\n/)
buf = lines.pop() || ""
for (const line of lines) {
if (line) push(line)
}
return buf
}
function fail(queue: Queue.Queue<string, Error | Cause.Done>, err: Error) {
Queue.failCauseUnsafe(queue, Cause.fail(err))
}
function searchDirect(input: SearchInput) {
return Effect.tryPromise({
try: () =>
ripgrep(searchArgs(input), {
buffer: true,
...opts(input.cwd),
}),
catch: toError,
}).pipe(
Effect.flatMap((ret) => {
const out = ret.stdout ?? ""
if (ret.code !== 0 && ret.code !== 1 && ret.code !== 2) {
return Effect.fail(error(ret.stderr ?? "", ret.code ?? 1))
}
return Effect.sync(() => ({
items: ret.code === 1 ? [] : parse(out),
partial: ret.code === 2,
}))
}),
)
}
function searchWorker(input: SearchInput) {
if (input.signal?.aborted) return Effect.fail(abort(input.signal))
return Effect.acquireUseRelease(
worker(),
(w) =>
Effect.callback<SearchResult, Error>((resume, signal) => {
let open = true
const done = (effect: Effect.Effect<SearchResult, Error>) => {
if (!open) return
open = false
resume(effect)
}
const onabort = () => done(Effect.fail(abort(input.signal)))
w.onerror = (evt) => {
done(Effect.fail(toError(evt.error ?? evt.message)))
}
w.onmessage = (evt: MessageEvent<WorkerResult | WorkerError>) => {
const msg = evt.data
if (msg.type === "error") {
done(Effect.fail(Object.assign(new Error(msg.error.message), msg.error)))
return
}
if (msg.code === 1) {
done(Effect.succeed({ items: [], partial: false }))
return
}
if (msg.code !== 0 && msg.code !== 1 && msg.code !== 2) {
done(Effect.fail(error(msg.stderr, msg.code)))
return
}
done(
Effect.sync(() => ({
items: parse(msg.stdout),
partial: msg.code === 2,
})),
)
}
input.signal?.addEventListener("abort", onabort, { once: true })
signal.addEventListener("abort", onabort, { once: true })
w.postMessage({
kind: "search",
cwd: input.cwd,
args: searchArgs(input),
} satisfies Run)
return Effect.sync(() => {
input.signal?.removeEventListener("abort", onabort)
signal.removeEventListener("abort", onabort)
w.onerror = null
w.onmessage = null
})
}),
(w) => Effect.sync(() => w.terminate()),
)
}
function filesDirect(input: FilesInput) {
return Stream.callback<string, Error>(
Effect.fnUntraced(function* (queue: Queue.Queue<string, Error | Cause.Done>) {
let buf = ""
let err = ""
const out = {
write(chunk: unknown) {
buf = drain(buf, chunk, (line) => {
Queue.offerUnsafe(queue, clean(line))
})
},
}
const stderr = {
write(chunk: unknown) {
err += text(chunk)
},
}
yield* Effect.forkScoped(
Effect.gen(function* () {
yield* check(input.cwd)
const ret = yield* Effect.tryPromise({
try: () =>
ripgrep(filesArgs(input), {
stdout: out,
stderr,
...opts(input.cwd),
}),
catch: toError,
})
if (buf) Queue.offerUnsafe(queue, clean(buf))
if (ret.code === 0 || ret.code === 1) {
Queue.endUnsafe(queue)
return
}
fail(queue, error(err, ret.code ?? 1))
}).pipe(
Effect.catch((err) =>
Effect.sync(() => {
fail(queue, err)
}),
),
),
)
}),
)
}
function filesWorker(input: FilesInput) {
return Stream.callback<string, Error>(
Effect.fnUntraced(function* (queue: Queue.Queue<string, Error | Cause.Done>) {
if (input.signal?.aborted) {
fail(queue, abort(input.signal))
return
}
const w = yield* Effect.acquireRelease(worker(), (w) => Effect.sync(() => w.terminate()))
let open = true
const close = () => {
if (!open) return false
open = false
return true
}
const onabort = () => {
if (!close()) return
fail(queue, abort(input.signal))
}
w.onerror = (evt) => {
if (!close()) return
fail(queue, toError(evt.error ?? evt.message))
}
w.onmessage = (evt: MessageEvent<WorkerLine | WorkerDone | WorkerError>) => {
const msg = evt.data
if (msg.type === "line") {
if (open) Queue.offerUnsafe(queue, msg.line)
return
}
if (!close()) return
if (msg.type === "error") {
fail(queue, Object.assign(new Error(msg.error.message), msg.error))
return
}
if (msg.code === 0 || msg.code === 1) {
Queue.endUnsafe(queue)
return
}
fail(queue, error(msg.stderr, msg.code))
}
yield* Effect.acquireRelease(
Effect.sync(() => {
input.signal?.addEventListener("abort", onabort, { once: true })
w.postMessage({
kind: "files",
cwd: input.cwd,
args: filesArgs(input),
} satisfies Run)
}),
() =>
Effect.sync(() => {
input.signal?.removeEventListener("abort", onabort)
w.onerror = null
w.onmessage = null
}),
)
}),
)
}
export const layer = Layer.effect(
export const layer: Layer.Layer<Service, never, ChildProcessSpawner | AppFileSystem.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const source = (input: FilesInput) => {
const useWorker = !!input.signal && typeof Worker !== "undefined"
if (!useWorker && input.signal) {
log.warn("worker unavailable, ripgrep abort disabled")
}
return useWorker ? filesWorker(input) : filesDirect(input)
}
const files: Interface["files"] = (input) => source(input)
const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) {
log.info("tree", input)
const list = Array.from(yield* source({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect))
interface Node {
name: string
children: Map<string, Node>
}
function child(node: Node, name: string) {
const item = node.children.get(name)
if (item) return item
const next = { name, children: new Map() }
node.children.set(name, next)
return next
}
function count(node: Node): number {
return Array.from(node.children.values()).reduce((sum, child) => sum + 1 + count(child), 0)
}
const root: Node = { name: "", children: new Map() }
for (const file of list) {
if (file.includes(".opencode")) continue
const parts = file.split(path.sep)
if (parts.length < 2) continue
let node = root
for (const part of parts.slice(0, -1)) {
node = child(node, part)
const spawner = yield* ChildProcessSpawner
const afs = yield* AppFileSystem.Service
const bin = Effect.fn("Ripgrep.path")(function* () {
return yield* Effect.promise(() => filepath())
})
const args = Effect.fn("Ripgrep.args")(function* (input: {
mode: "files" | "search"
glob?: string[]
hidden?: boolean
follow?: boolean
maxDepth?: number
limit?: number
pattern?: string
file?: string[]
}) {
const out = [yield* bin(), input.mode === "search" ? "--json" : "--files", "--glob=!.git/*"]
if (input.follow) out.push("--follow")
if (input.hidden !== false) out.push("--hidden")
if (input.maxDepth !== undefined) out.push(`--max-depth=${input.maxDepth}`)
if (input.glob) {
for (const g of input.glob) {
out.push(`--glob=${g}`)
}
}
if (input.limit) out.push(`--max-count=${input.limit}`)
if (input.mode === "search") out.push("--no-messages")
if (input.pattern) out.push("--", input.pattern, ...(input.file ?? []))
return out
})
const total = count(root)
const limit = input.limit ?? total
const lines: string[] = []
const queue: Array<{ node: Node; path: string }> = Array.from(root.children.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map((node) => ({ node, path: node.name }))
let used = 0
for (let i = 0; i < queue.length && used < limit; i++) {
const item = queue[i]
lines.push(item.path)
used++
queue.push(
...Array.from(item.node.children.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map((node) => ({ node, path: `${item.path}/${node.name}` })),
const files = Effect.fn("Ripgrep.files")(function* (input: {
cwd: string
glob?: string[]
hidden?: boolean
follow?: boolean
maxDepth?: number
}) {
const rgPath = yield* bin()
const isDir = yield* afs.isDir(input.cwd)
if (!isDir) {
return yield* Effect.die(
Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
code: "ENOENT" as const,
errno: -2,
path: input.cwd,
}),
)
}
if (total > used) lines.push(`[${total - used} truncated]`)
return lines.join("\n")
const cmd = yield* args({
mode: "files",
glob: input.glob,
hidden: input.hidden,
follow: input.follow,
maxDepth: input.maxDepth,
})
return spawner
.streamLines(ChildProcess.make(cmd[0], cmd.slice(1), { cwd: input.cwd }))
.pipe(Stream.filter((line: string) => line.length > 0))
})
const search: Interface["search"] = Effect.fn("Ripgrep.search")(function* (input: SearchInput) {
const useWorker = !!input.signal && typeof Worker !== "undefined"
if (!useWorker && input.signal) {
log.warn("worker unavailable, ripgrep abort disabled")
}
return yield* useWorker ? searchWorker(input) : searchDirect(input)
const search = Effect.fn("Ripgrep.search")(function* (input: {
cwd: string
pattern: string
glob?: string[]
limit?: number
follow?: boolean
file?: string[]
}) {
return yield* Effect.scoped(
Effect.gen(function* () {
const cmd = yield* args({
mode: "search",
glob: input.glob,
follow: input.follow,
limit: input.limit,
pattern: input.pattern,
file: input.file,
})
const handle = yield* spawner.spawn(
ChildProcess.make(cmd[0], cmd.slice(1), {
cwd: input.cwd,
stdin: "ignore",
}),
)
const [items, stderr, code] = yield* Effect.all(
[
Stream.decodeText(handle.stdout).pipe(
Stream.splitLines,
Stream.filter((line) => line.length > 0),
Stream.mapEffect((line) =>
decode(line).pipe(Effect.mapError((cause) => new Error("invalid ripgrep output", { cause }))),
),
Stream.filter((row): row is Schema.Schema.Type<typeof Hit> => row.type === "match"),
Stream.map((row): Item => row.data),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
),
Stream.mkString(Stream.decodeText(handle.stderr)),
handle.exitCode,
],
{ concurrency: "unbounded" },
)
if (code !== 0 && code !== 1 && code !== 2) {
return yield* Effect.fail(new Error(`ripgrep failed: ${stderr}`))
}
return {
items,
partial: code === 2,
}
}),
)
})
return Service.of({ files, tree, search })
return Service.of({
files: (input) => Stream.unwrap(files(input)),
search,
})
}),
)
export const defaultLayer = layer
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function tree(input: { cwd: string; limit?: number; signal?: AbortSignal }) {
log.info("tree", input)
const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal }))
interface Node {
name: string
children: Map<string, Node>
}
export function files(input: FilesInput) {
return runPromise((svc) => Stream.toAsyncIterableEffect(svc.files(input)))
}
function dir(node: Node, name: string) {
const existing = node.children.get(name)
if (existing) return existing
const next = { name, children: new Map() }
node.children.set(name, next)
return next
}
export function tree(input: TreeInput) {
return runPromise((svc) => svc.tree(input))
}
const root: Node = { name: "", children: new Map() }
for (const file of files) {
if (file.includes(".opencode")) continue
const parts = file.split(path.sep)
if (parts.length < 2) continue
let node = root
for (const part of parts.slice(0, -1)) {
node = dir(node, part)
}
}
export function search(input: SearchInput) {
return runPromise((svc) => svc.search(input))
function count(node: Node): number {
let total = 0
for (const child of node.children.values()) {
total += 1 + count(child)
}
return total
}
const total = count(root)
const limit = input.limit ?? total
const lines: string[] = []
const queue: { node: Node; path: string }[] = []
for (const child of Array.from(root.children.values()).sort((a, b) => a.name.localeCompare(b.name))) {
queue.push({ node: child, path: child.name })
}
let used = 0
for (let i = 0; i < queue.length && used < limit; i++) {
const { node, path } = queue[i]
lines.push(path)
used++
for (const child of Array.from(node.children.values()).sort((a, b) => a.name.localeCompare(b.name))) {
queue.push({ node: child, path: `${path}/${child.name}` })
}
}
if (total > used) lines.push(`[${total - used} truncated]`)
return lines.join("\n")
}
}
@@ -1,103 +0,0 @@
import { ripgrep } from "ripgrep"
function env() {
const env = Object.fromEntries(
Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined),
)
delete env.RIPGREP_CONFIG_PATH
return env
}
function opts(cwd: string) {
return {
env: env(),
preopens: { ".": cwd },
}
}
type Run = {
kind: "files" | "search"
cwd: string
args: string[]
}
function text(input: unknown) {
if (typeof input === "string") return input
if (input instanceof ArrayBuffer) return Buffer.from(input).toString()
if (ArrayBuffer.isView(input)) return Buffer.from(input.buffer, input.byteOffset, input.byteLength).toString()
return String(input)
}
function error(input: unknown) {
if (input instanceof Error) {
return {
message: input.message,
name: input.name,
stack: input.stack,
}
}
return {
message: String(input),
}
}
function clean(file: string) {
return file.replace(/^\.[\\/]/, "")
}
onmessage = async (evt: MessageEvent<Run>) => {
const msg = evt.data
try {
if (msg.kind === "search") {
const ret = await ripgrep(msg.args, {
buffer: true,
...opts(msg.cwd),
})
postMessage({
type: "result",
code: ret.code ?? 0,
stdout: ret.stdout ?? "",
stderr: ret.stderr ?? "",
})
return
}
let buf = ""
let err = ""
const out = {
write(chunk: unknown) {
buf += text(chunk)
const lines = buf.split(/\r?\n/)
buf = lines.pop() || ""
for (const line of lines) {
if (line) postMessage({ type: "line", line: clean(line) })
}
},
}
const stderr = {
write(chunk: unknown) {
err += text(chunk)
},
}
const ret = await ripgrep(msg.args, {
stdout: out,
stderr,
...opts(msg.cwd),
})
if (buf) postMessage({ type: "line", line: clean(buf) })
postMessage({
type: "done",
code: ret.code ?? 0,
stderr: err,
})
} catch (err) {
postMessage({
type: "error",
error: error(err),
})
}
}
-465
View File
@@ -1,465 +0,0 @@
import path from "path"
import z from "zod"
import { Context, Deferred, Effect, Layer, Option } from "effect"
import * as Stream from "effect/Stream"
import { Fff } from "#fff"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { Global } from "@/global"
import { Glob } from "@/util/glob"
import { Log } from "@/util/log"
import { Ripgrep } from "./ripgrep"
export namespace Search {
const log = Log.create({ service: "file.search" })
const root = path.join(Global.Path.cache, "fff")
export const Match = z.object({
path: z.object({
text: z.string(),
}),
lines: z.object({
text: z.string(),
}),
line_number: z.number(),
absolute_offset: z.number(),
submatches: z.array(
z.object({
match: z.object({
text: z.string(),
}),
start: z.number(),
end: z.number(),
}),
),
})
export type Item = z.infer<typeof Match>
export interface Result {
readonly items: Item[]
readonly partial: boolean
readonly engine: "fff" | "ripgrep"
readonly regexFallbackError?: string
}
export interface FileInput {
readonly cwd: string
readonly query: string
readonly limit?: number
readonly current?: string
}
export interface GlobInput {
readonly cwd: string
readonly pattern: string
readonly limit?: number
readonly signal?: AbortSignal
}
interface Query {
readonly dir: string
readonly text: string
readonly files: string[]
}
interface State {
readonly pick: Map<string, Fff.Picker>
readonly wait: Map<string, Deferred.Deferred<Fff.Picker, Error>>
readonly recent: Query[]
}
export interface Interface {
readonly files: (input: Ripgrep.FilesInput) => Stream.Stream<string, Error>
readonly tree: (input: Ripgrep.TreeInput) => Effect.Effect<string, Error>
readonly search: (input: Ripgrep.SearchInput) => Effect.Effect<Result, Error>
readonly file: (input: FileInput) => Effect.Effect<string[]>
readonly glob: (input: GlobInput) => Effect.Effect<{ files: string[]; truncated: boolean }, Error>
readonly open: (input: { cwd?: string; file: string }) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Search") {}
function key(dir: string) {
return Buffer.from(dir).toString("base64url")
}
function norm(text: string) {
return text.replaceAll("\\", "/")
}
function blocked(rel: string) {
return norm(rel).split("/").includes(".git")
}
function base(file: string) {
return norm(file).split("/").at(-1) ?? file
}
function allow(glob: string[] | undefined, rel: string, file: string) {
if (!glob?.length) return true
const yes = glob.filter((item) => !item.startsWith("!"))
const no = glob.filter((item) => item.startsWith("!")).map((item) => item.slice(1))
if (yes.length > 0 && !yes.some((item) => Glob.match(item, rel) || Glob.match(item, file))) return false
if (no.some((item) => Glob.match(item, rel) || Glob.match(item, file))) return false
return true
}
function include(pattern: string) {
const val = pattern.trim().replaceAll("\\", "/")
if (!val) return "*"
const flat = val.replaceAll("**/", "").replaceAll("/**", "/")
const idx = flat.lastIndexOf("/")
if (idx < 0) return flat
const dir = flat.slice(0, idx + 1)
const glob = flat.slice(idx + 1)
if (!glob) return dir
return `${dir} ${glob}`
}
function remember(st: State, dir: string, text: string, files: string[]) {
if (!files.length) return
const next = Array.from(new Set(files.map(AppFileSystem.resolve))).slice(0, 64)
if (!next.length) return
const old = st.recent.findIndex((item) => item.dir === dir && item.text === text)
if (old >= 0) st.recent.splice(old, 1)
st.recent.unshift({ dir, text, files: next })
if (st.recent.length > 32) st.recent.length = 32
}
function item(hit: Fff.Hit): Item {
return {
path: { text: norm(hit.relativePath) },
lines: { text: hit.lineContent },
line_number: hit.lineNumber,
absolute_offset: hit.byteOffset,
submatches: hit.matchRanges
.map(([start, end]) => {
const text = hit.lineContent.slice(start, end)
if (!text) return undefined
return {
match: { text },
start,
end,
}
})
.filter((row): row is Item["submatches"][number] => Boolean(row)),
}
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const rg = yield* Ripgrep.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Search.state")(() =>
Effect.gen(function* () {
const next: State = {
pick: new Map(),
wait: new Map(),
recent: [],
}
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const pick of next.pick.values()) pick.destroy()
}),
)
return next
}),
),
)
const rip = Effect.fn("Search.rip")(function* (input: Ripgrep.SearchInput) {
const out = yield* rg.search(input)
return {
items: out.items,
partial: out.partial,
engine: "ripgrep" as const,
}
})
const picker = Effect.fn("Search.picker")(function* (cwd: string) {
if (!Fff.available()) return undefined
const dir = AppFileSystem.resolve(cwd)
const st = yield* InstanceState.get(state)
const old = st.pick.get(dir)
if (old) return old
const wait = st.wait.get(dir)
if (wait) return yield* Deferred.await(wait)
const gate = yield* Deferred.make<Fff.Picker, Error>()
st.wait.set(dir, gate)
try {
yield* fs.ensureDir(root)
const id = key(dir)
const made = yield* Effect.sync(() =>
Fff.create({
basePath: dir,
frecencyDbPath: path.join(root, `${id}.frecency.mdb`),
historyDbPath: path.join(root, `${id}.history.mdb`),
aiMode: true,
}),
)
if (!made.ok) {
const err = new Error(made.error)
yield* Deferred.fail(gate, err)
return yield* Effect.fail(err)
}
const pick = made.value
const done = yield* Effect.sync(() => pick.waitForScan(5_000))
if (!done.ok) {
pick.destroy()
const err = new Error(done.error)
yield* Deferred.fail(gate, err)
return yield* Effect.fail(err)
}
if (!done.value) {
pick.destroy()
const err = new Error("fff scan timed out")
yield* Deferred.fail(gate, err)
return yield* Effect.fail(err)
}
const git = yield* Effect.sync(() => pick.refreshGitStatus())
if (!git.ok) {
log.warn("git refresh failed", { dir, error: git.error })
}
st.pick.set(dir, pick)
yield* Deferred.succeed(gate, pick)
return pick
} finally {
if (st.wait.get(dir) === gate) st.wait.delete(dir)
}
})
const files: Interface["files"] = (input) => rg.files(input)
const tree: Interface["tree"] = (input) => rg.tree(input)
const file: Interface["file"] = Effect.fn("Search.file")(function* (input) {
const query = input.query.trim()
if (!query) return []
const pick = yield* picker(input.cwd).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!pick) return []
const dir = AppFileSystem.resolve(input.cwd)
const out = yield* Effect.sync(() =>
pick.fileSearch(query, {
currentFile: input.current
? path.isAbsolute(input.current)
? input.current
: path.join(dir, input.current)
: undefined,
pageIndex: 0,
pageSize: Math.max(input.limit ?? 100, 100),
}),
)
if (!out.ok) {
log.warn("fff file search failed", { dir, query, error: out.error })
return []
}
const min = query.length * 10
const rows = Array.from(
new Set(
out.value.items.flatMap((item, idx) => {
const score = out.value.scores[idx]
if (!score || score.total < min) return []
return [norm(item.relativePath)]
}),
),
)
remember(
yield* InstanceState.get(state),
dir,
query,
rows.map((row) => path.join(dir, row)),
)
return rows.slice(0, input.limit ?? 100)
})
const search: Interface["search"] = Effect.fn("Search.search")(function* (input) {
input.signal?.throwIfAborted()
if (input.file?.length) return yield* rip(input)
const pick = yield* picker(input.cwd).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!pick) return yield* rip(input)
const dir = AppFileSystem.resolve(input.cwd)
const limit = input.limit ?? 100
const rows: Item[] = []
const seen = new Set<string>()
let cur: Fff.Cursor = null
let err: string | undefined
while (rows.length < limit) {
input.signal?.throwIfAborted()
const out = yield* Effect.sync(() =>
pick.grep(input.pattern, {
mode: "regex",
cursor: cur,
maxMatchesPerFile: limit,
timeBudgetMs: 1_500,
}),
)
if (!out.ok) {
log.warn("fff grep failed", { dir, pattern: input.pattern, error: out.error })
return yield* rip(input)
}
err = err ?? out.value.regexFallbackError
for (const hit of out.value.items) {
const rel = norm(hit.relativePath)
if (!allow(input.glob, rel, norm(hit.fileName))) continue
const id = `${rel}:${hit.lineNumber}:${hit.byteOffset}`
if (seen.has(id)) continue
seen.add(id)
rows.push(item(hit))
if (rows.length >= limit) break
}
if (!out.value.nextCursor) break
cur = out.value.nextCursor
}
if (!rows.length && input.glob?.length) return yield* rip(input)
remember(
yield* InstanceState.get(state),
dir,
input.pattern,
Array.from(new Set(rows.map((row) => path.join(dir, row.path.text)))),
)
return {
items: rows,
partial: false,
engine: "fff" as const,
regexFallbackError: err,
}
})
const glob: Interface["glob"] = Effect.fn("Search.glob")(function* (input) {
input.signal?.throwIfAborted()
const dir = AppFileSystem.resolve(input.cwd)
const limit = input.limit ?? 100
const pick = yield* picker(dir).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (pick) {
const out = yield* Effect.sync(() =>
pick.fileSearch(include(input.pattern), {
currentFile: path.join(dir, ".opencode"),
pageIndex: 0,
pageSize: Math.max(limit * 4, 200),
}),
)
if (out.ok) {
const rows = Array.from(
new Set(
out.value.items
.map((item) => norm(item.relativePath))
.filter((item) => !blocked(item))
.filter((item) => Glob.match(input.pattern, item) || Glob.match(input.pattern, base(item))),
),
)
if (rows.length > 0) {
remember(
yield* InstanceState.get(state),
dir,
input.pattern,
rows.map((row) => path.join(dir, row)),
)
return {
files: rows.slice(0, limit).map((row) => path.join(dir, row)),
truncated: rows.length > limit,
}
}
} else {
log.warn("fff glob search failed", { dir, pattern: input.pattern, error: out.error })
}
}
const rows = yield* rg.files({ cwd: dir, glob: [input.pattern], signal: input.signal }).pipe(
Stream.take(limit + 1),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
const cut = rows.length > limit
if (cut) rows.length = limit
const out = yield* Effect.forEach(
rows,
Effect.fnUntraced(function* (file) {
const full = path.join(dir, file)
const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
const time =
info?.mtime.pipe(
Option.map((item) => item.getTime()),
Option.getOrElse(() => 0),
) ?? 0
return { file: full, time }
}),
{ concurrency: 16 },
)
out.sort((a, b) => b.time - a.time)
return {
files: out.map((item) => item.file),
truncated: cut,
}
})
const open: Interface["open"] = Effect.fn("Search.open")(function* (input) {
const st = yield* InstanceState.get(state)
const file = input.cwd
? AppFileSystem.resolve(path.isAbsolute(input.file) ? input.file : path.join(input.cwd, input.file))
: AppFileSystem.resolve(input.file)
const idx = st.recent.findIndex((item) => item.files.includes(file))
if (idx < 0) return
const row = st.recent[idx]
st.recent.splice(idx, 1)
const pick = st.pick.get(row.dir)
if (!pick) return
const out = yield* Effect.sync(() => pick.trackQuery(row.text, file))
if (!out.ok) {
log.warn("fff track query failed", { dir: row.dir, query: row.text, file, error: out.error })
}
})
return Service.of({ files, tree, search, file, glob, open })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Ripgrep.defaultLayer), Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export function files(input: Ripgrep.FilesInput) {
return runPromise((svc) => Stream.toAsyncIterableEffect(svc.files(input)))
}
export function tree(input: Ripgrep.TreeInput) {
return runPromise((svc) => svc.tree(input))
}
export function search(input: Ripgrep.SearchInput) {
return runPromise((svc) => svc.search(input))
}
export function file(input: FileInput) {
return runPromise((svc) => svc.file(input))
}
export function glob(input: GlobInput) {
return runPromise((svc) => svc.glob(input))
}
export function open(input: { cwd?: string; file: string }) {
return runPromise((svc) => svc.open(input))
}
}
+4 -1
View File
@@ -3,6 +3,7 @@ import { disposeInstance } from "@/effect/instance-registry"
import { Filesystem } from "@/util/filesystem"
import { iife } from "@/util/iife"
import { Log } from "@/util/log"
import { Effect } from "effect"
import { LocalContext } from "../util/local-context"
import { Project } from "./project"
import { WorkspaceContext } from "@/control-plane/workspace-context"
@@ -29,7 +30,9 @@ function boot(input: { directory: string; init?: () => Promise<any>; worktree?:
worktree: input.worktree,
project: input.project,
}
: await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({
: await Effect.runPromise(
Project.Service.use((svc) => svc.fromDirectory(input.directory)).pipe(Effect.provide(Project.defaultLayer)),
).then(({ project, sandbox }) => ({
directory: input.directory,
worktree: sandbox,
project,
+1 -35
View File
@@ -10,8 +10,7 @@ import { which } from "../util/which"
import { ProjectID } from "./schema"
import { Effect, Layer, Path, Scope, Context, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { makeRuntime } from "@/effect/run-service"
import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "@/filesystem"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
@@ -463,19 +462,6 @@ export namespace Project {
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodePath.layer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
// ---------------------------------------------------------------------------
// Promise-based API (delegates to Effect service via runPromise)
// ---------------------------------------------------------------------------
export function fromDirectory(directory: string) {
return runPromise((svc) => svc.fromDirectory(directory))
}
export function discover(input: Info) {
return runPromise((svc) => svc.discover(input))
}
export function list() {
return Database.use((db) =>
@@ -498,24 +484,4 @@ export namespace Project {
db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
)
}
export function initGit(input: { directory: string; project: Info }) {
return runPromise((svc) => svc.initGit(input))
}
export function update(input: UpdateInput) {
return runPromise((svc) => svc.update(input))
}
export function sandboxes(id: ProjectID) {
return runPromise((svc) => svc.sandboxes(id))
}
export function addSandbox(id: ProjectID, directory: string) {
return runPromise((svc) => svc.addSandbox(id, directory))
}
export function removeSandbox(id: ProjectID, directory: string) {
return runPromise((svc) => svc.removeSandbox(id, directory))
}
}
@@ -276,7 +276,7 @@ export const ExperimentalRoutes = lazy(() =>
},
}),
async (c) => {
const sandboxes = await Project.sandboxes(Instance.project.id)
const sandboxes = await AppRuntime.runPromise(Project.Service.use((svc) => svc.sandboxes(Instance.project.id)))
return c.json(sandboxes)
},
)
@@ -302,7 +302,9 @@ export const ExperimentalRoutes = lazy(() =>
async (c) => {
const body = c.req.valid("json")
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove(body)))
await Project.removeSandbox(Instance.project.id, body.directory)
await AppRuntime.runPromise(
Project.Service.use((svc) => svc.removeSandbox(Instance.project.id, body.directory)),
)
return c.json(true)
},
)
@@ -4,7 +4,7 @@ import { Effect } from "effect"
import z from "zod"
import { AppRuntime } from "../../effect/app-runtime"
import { File } from "../../file"
import { Search } from "../../file/search"
import { Ripgrep } from "../../file/ripgrep"
import { LSP } from "../../lsp"
import { Instance } from "../../project/instance"
import { lazy } from "../../util/lazy"
@@ -15,14 +15,14 @@ export const FileRoutes = lazy(() =>
"/find",
describeRoute({
summary: "Find text",
description: "Search for text patterns across files in the project.",
description: "Search for text patterns across files in the project using ripgrep.",
operationId: "find.text",
responses: {
200: {
description: "Matches",
content: {
"application/json": {
schema: resolver(Search.Match.array()),
schema: resolver(Ripgrep.Match.shape.data.array()),
},
},
},
@@ -37,7 +37,7 @@ export const FileRoutes = lazy(() =>
async (c) => {
const pattern = c.req.valid("query").pattern
const result = await AppRuntime.runPromise(
Search.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })),
Ripgrep.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })),
)
return c.json(result.items)
},
@@ -41,7 +41,7 @@ async function getSessionWorkspace(url: URL) {
const id = getSessionID(url)
if (!id) return null
const session = await Session.get(id).catch(() => undefined)
const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(id))).catch(() => undefined)
return session?.workspaceID
}
@@ -75,10 +75,9 @@ export const ProjectRoutes = lazy(() =>
async (c) => {
const dir = Instance.directory
const prev = Instance.project
const next = await Project.initGit({
directory: dir,
project: prev,
})
const next = await AppRuntime.runPromise(
Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })),
)
if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next)
await Instance.reload({
directory: dir,
@@ -112,7 +111,7 @@ export const ProjectRoutes = lazy(() =>
async (c) => {
const projectID = c.req.valid("param").projectID
const body = c.req.valid("json")
const project = await Project.update({ ...body, projectID })
const project = await AppRuntime.runPromise(Project.Service.use((svc) => svc.update({ ...body, projectID })))
return c.json(project)
},
),
@@ -121,12 +121,12 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: Session.get.schema,
sessionID: Session.GetInput,
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const session = await Session.get(sessionID)
const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
return c.json(session)
},
)
@@ -152,12 +152,12 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: Session.children.schema,
sessionID: Session.ChildrenInput,
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const session = await Session.children(sessionID)
const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.children(sessionID)))
return c.json(session)
},
)
@@ -209,10 +209,10 @@ export const SessionRoutes = lazy(() =>
},
},
}),
validator("json", Session.create.schema),
validator("json", Session.CreateInput),
async (c) => {
const body = c.req.valid("json") ?? {}
const session = await SessionShare.create(body)
const session = await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.create(body)))
return c.json(session)
},
)
@@ -237,12 +237,12 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: Session.remove.schema,
sessionID: Session.RemoveInput,
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
await Session.remove(sessionID)
await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID)))
return c.json(true)
},
)
@@ -285,22 +285,27 @@ export const SessionRoutes = lazy(() =>
async (c) => {
const sessionID = c.req.valid("param").sessionID
const updates = c.req.valid("json")
const current = await Session.get(sessionID)
const session = await AppRuntime.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
const current = yield* session.get(sessionID)
if (updates.title !== undefined) {
await Session.setTitle({ sessionID, title: updates.title })
}
if (updates.permission !== undefined) {
await Session.setPermission({
sessionID,
permission: Permission.merge(current.permission ?? [], updates.permission),
})
}
if (updates.time?.archived !== undefined) {
await Session.setArchived({ sessionID, time: updates.time.archived })
}
if (updates.title !== undefined) {
yield* session.setTitle({ sessionID, title: updates.title })
}
if (updates.permission !== undefined) {
yield* session.setPermission({
sessionID,
permission: Permission.merge(current.permission ?? [], updates.permission),
})
}
if (updates.time?.archived !== undefined) {
yield* session.setArchived({ sessionID, time: updates.time.archived })
}
const session = await Session.get(sessionID)
return yield* session.get(sessionID)
}),
)
return c.json(session)
},
)
@@ -341,13 +346,17 @@ export const SessionRoutes = lazy(() =>
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
await SessionPrompt.command({
sessionID,
messageID: body.messageID,
model: body.providerID + "/" + body.modelID,
command: Command.Default.INIT,
arguments: "",
})
await AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.command({
sessionID,
messageID: body.messageID,
model: body.providerID + "/" + body.modelID,
command: Command.Default.INIT,
arguments: "",
}),
),
)
return c.json(true)
},
)
@@ -371,14 +380,14 @@ export const SessionRoutes = lazy(() =>
validator(
"param",
z.object({
sessionID: Session.fork.schema.shape.sessionID,
sessionID: Session.ForkInput.shape.sessionID,
}),
),
validator("json", Session.fork.schema.omit({ sessionID: true })),
validator("json", Session.ForkInput.omit({ sessionID: true })),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const result = await Session.fork({ ...body, sessionID })
const result = await AppRuntime.runPromise(Session.Service.use((svc) => svc.fork({ ...body, sessionID })))
return c.json(result)
},
)
@@ -407,7 +416,7 @@ export const SessionRoutes = lazy(() =>
}),
),
async (c) => {
await SessionPrompt.cancel(c.req.valid("param").sessionID)
await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.cancel(c.req.valid("param").sessionID)))
return c.json(true)
},
)
@@ -437,8 +446,14 @@ export const SessionRoutes = lazy(() =>
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
await SessionShare.share(sessionID)
const session = await Session.get(sessionID)
const session = await AppRuntime.runPromise(
Effect.gen(function* () {
const share = yield* SessionShare.Service
const session = yield* Session.Service
yield* share.share(sessionID)
return yield* session.get(sessionID)
}),
)
return c.json(session)
},
)
@@ -511,8 +526,14 @@ export const SessionRoutes = lazy(() =>
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
await SessionShare.unshare(sessionID)
const session = await Session.get(sessionID)
const session = await AppRuntime.runPromise(
Effect.gen(function* () {
const share = yield* SessionShare.Service
const session = yield* Session.Service
yield* share.unshare(sessionID)
return yield* session.get(sessionID)
}),
)
return c.json(session)
},
)
@@ -645,15 +666,14 @@ export const SessionRoutes = lazy(() =>
async (c) => {
const query = c.req.valid("query")
const sessionID = c.req.valid("param").sessionID
if (query.limit === undefined) {
await Session.get(sessionID)
const messages = await Session.messages({ sessionID })
return c.json(messages)
}
if (query.limit === 0) {
await Session.get(sessionID)
const messages = await Session.messages({ sessionID })
if (query.limit === undefined || query.limit === 0) {
const messages = await AppRuntime.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
yield* session.get(sessionID)
return yield* session.messages({ sessionID })
}),
)
return c.json(messages)
}
@@ -781,11 +801,15 @@ export const SessionRoutes = lazy(() =>
),
async (c) => {
const params = c.req.valid("param")
await Session.removePart({
sessionID: params.sessionID,
messageID: params.messageID,
partID: params.partID,
})
await AppRuntime.runPromise(
Session.Service.use((svc) =>
svc.removePart({
sessionID: params.sessionID,
messageID: params.messageID,
partID: params.partID,
}),
),
)
return c.json(true)
},
)
@@ -823,7 +847,7 @@ export const SessionRoutes = lazy(() =>
`Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`,
)
}
const part = await Session.updatePart(body)
const part = await AppRuntime.runPromise(Session.Service.use((svc) => svc.updatePart(body)))
return c.json(part)
},
)
@@ -863,7 +887,9 @@ export const SessionRoutes = lazy(() =>
return stream(c, async (stream) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const msg = await SessionPrompt.prompt({ ...body, sessionID })
const msg = await AppRuntime.runPromise(
SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
)
stream.write(JSON.stringify(msg))
})
},
@@ -892,7 +918,7 @@ export const SessionRoutes = lazy(() =>
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
SessionPrompt.prompt({ ...body, sessionID }).catch((err) => {
AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID }))).catch((err) => {
log.error("prompt_async failed", { sessionID, error: err })
Bus.publish(Session.Event.Error, {
sessionID,
@@ -936,7 +962,7 @@ export const SessionRoutes = lazy(() =>
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const msg = await SessionPrompt.command({ ...body, sessionID })
const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.command({ ...body, sessionID })))
return c.json(msg)
},
)
@@ -968,7 +994,7 @@ export const SessionRoutes = lazy(() =>
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const msg = await SessionPrompt.shell({ ...body, sessionID })
const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.shell({ ...body, sessionID })))
return c.json(msg)
},
)
+2 -1
View File
@@ -4,6 +4,7 @@ import z from "zod"
import { Bus } from "../../bus"
import { Session } from "../../session"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { AppRuntime } from "@/effect/app-runtime"
import { AsyncQueue } from "../../util/queue"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -370,7 +371,7 @@ export const TuiRoutes = lazy(() =>
validator("json", TuiEvent.SessionSelect.properties),
async (c) => {
const { sessionID } = c.req.valid("json")
await Session.get(sessionID)
await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
await Bus.publish(TuiEvent.SessionSelect, { sessionID })
return c.json(true)
},
+24 -65
View File
@@ -19,7 +19,6 @@ import { updateSchema } from "../util/update-schema"
import { MessageV2 } from "./message-v2"
import { Instance } from "../project/instance"
import { InstanceState } from "@/effect/instance-state"
import { fn } from "@/util/fn"
import { Snapshot } from "@/snapshot"
import { ProjectID } from "../project/schema"
import { WorkspaceID } from "../control-plane/schema"
@@ -29,7 +28,6 @@ import type { Provider } from "@/provider/provider"
import { Permission } from "@/permission"
import { Global } from "@/global"
import { Effect, Layer, Option, Context } from "effect"
import { makeRuntime } from "@/effect/run-service"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -179,6 +177,30 @@ export namespace Session {
})
export type GlobalInfo = z.output<typeof GlobalInfo>
export const CreateInput = z
.object({
parentID: SessionID.zod.optional(),
title: z.string().optional(),
permission: Info.shape.permission,
workspaceID: WorkspaceID.zod.optional(),
})
.optional()
export type CreateInput = z.output<typeof CreateInput>
export const ForkInput = z.object({ sessionID: SessionID.zod, messageID: MessageID.zod.optional() })
export const GetInput = SessionID.zod
export const ChildrenInput = SessionID.zod
export const RemoveInput = SessionID.zod
export const SetTitleInput = z.object({ sessionID: SessionID.zod, title: z.string() })
export const SetArchivedInput = z.object({ sessionID: SessionID.zod, time: z.number().optional() })
export const SetPermissionInput = z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset })
export const SetRevertInput = z.object({
sessionID: SessionID.zod,
revert: Info.shape.revert,
summary: Info.shape.summary,
})
export const MessagesInput = z.object({ sessionID: SessionID.zod, limit: z.number().optional() })
export const Event = {
Created: SyncEvent.define({
type: "session.created",
@@ -682,48 +704,6 @@ export namespace Session {
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export const create = fn(
z
.object({
parentID: SessionID.zod.optional(),
title: z.string().optional(),
permission: Info.shape.permission,
workspaceID: WorkspaceID.zod.optional(),
})
.optional(),
(input) => runPromise((svc) => svc.create(input)),
)
export const fork = fn(z.object({ sessionID: SessionID.zod, messageID: MessageID.zod.optional() }), (input) =>
runPromise((svc) => svc.fork(input)),
)
export const get = fn(SessionID.zod, (id) => runPromise((svc) => svc.get(id)))
export const setTitle = fn(z.object({ sessionID: SessionID.zod, title: z.string() }), (input) =>
runPromise((svc) => svc.setTitle(input)),
)
export const setArchived = fn(z.object({ sessionID: SessionID.zod, time: z.number().optional() }), (input) =>
runPromise((svc) => svc.setArchived(input)),
)
export const setPermission = fn(z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset }), (input) =>
runPromise((svc) => svc.setPermission(input)),
)
export const setRevert = fn(
z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }),
(input) =>
runPromise((svc) => svc.setRevert({ sessionID: input.sessionID, revert: input.revert, summary: input.summary })),
)
export const messages = fn(z.object({ sessionID: SessionID.zod, limit: z.number().optional() }), (input) =>
runPromise((svc) => svc.messages(input)),
)
export function* list(input?: {
directory?: string
workspaceID?: WorkspaceID
@@ -835,25 +815,4 @@ export namespace Session {
yield { ...fromRow(row), project }
}
}
export const children = fn(SessionID.zod, (id) => runPromise((svc) => svc.children(id)))
export const remove = fn(SessionID.zod, (id) => runPromise((svc) => svc.remove(id)))
export async function updateMessage<T extends MessageV2.Info>(msg: T): Promise<T> {
MessageV2.Info.parse(msg)
return runPromise((svc) => svc.updateMessage(msg))
}
export const removeMessage = fn(z.object({ sessionID: SessionID.zod, messageID: MessageID.zod }), (input) =>
runPromise((svc) => svc.removeMessage(input)),
)
export const removePart = fn(
z.object({ sessionID: SessionID.zod, messageID: MessageID.zod, partID: PartID.zod }),
(input) => runPromise((svc) => svc.removePart(input)),
)
export async function updatePart<T extends MessageV2.Part>(part: T): Promise<T> {
MessageV2.Part.parse(part)
return runPromise((svc) => svc.updatePart(part))
}
}
+2 -30
View File
@@ -46,7 +46,6 @@ import { Process } from "@/util/process"
import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect"
import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state"
import { attach, makeRuntime } from "@/effect/run-service"
import { TaskTool, type TaskPromptOps } from "@/tool/task"
import { SessionRunState } from "./run-state"
@@ -108,9 +107,8 @@ export namespace SessionPrompt {
const run = {
promise: <A, E>(effect: Effect.Effect<A, E>) =>
Effect.runPromise(attach(effect).pipe(Effect.provide(EffectLogger.layer))),
fork: <A, E>(effect: Effect.Effect<A, E>) =>
Effect.runFork(attach(effect).pipe(Effect.provide(EffectLogger.layer))),
Effect.runPromise(effect.pipe(Effect.provide(EffectLogger.layer))),
fork: <A, E>(effect: Effect.Effect<A, E>) => Effect.runFork(effect.pipe(Effect.provide(EffectLogger.layer))),
}
const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
@@ -1709,8 +1707,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
),
),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export const PromptInput = z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod.optional(),
@@ -1778,26 +1774,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})
export type PromptInput = z.infer<typeof PromptInput>
export async function prompt(input: PromptInput) {
return runPromise((svc) => svc.prompt(PromptInput.parse(input)))
}
export async function resolvePromptParts(template: string) {
return runPromise((svc) => svc.resolvePromptParts(z.string().parse(template)))
}
export async function cancel(sessionID: SessionID) {
return runPromise((svc) => svc.cancel(SessionID.zod.parse(sessionID)))
}
export const LoopInput = z.object({
sessionID: SessionID.zod,
})
export async function loop(input: z.infer<typeof LoopInput>) {
return runPromise((svc) => svc.loop(LoopInput.parse(input)))
}
export const ShellInput = z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod.optional(),
@@ -1812,10 +1792,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})
export type ShellInput = z.infer<typeof ShellInput>
export async function shell(input: ShellInput) {
return runPromise((svc) => svc.shell(ShellInput.parse(input)))
}
export const CommandInput = z.object({
messageID: MessageID.zod.optional(),
sessionID: SessionID.zod,
@@ -1839,10 +1815,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})
export type CommandInput = z.infer<typeof CommandInput>
export async function command(input: CommandInput) {
return runPromise((svc) => svc.command(CommandInput.parse(input)))
}
/** @internal Exported for testing */
export function createStructuredOutputTool(input: {
schema: Record<string, any>
+2 -10
View File
@@ -1,8 +1,6 @@
import { makeRuntime } from "@/effect/run-service"
import { Session } from "@/session"
import { SessionID } from "@/session/schema"
import { SyncEvent } from "@/sync"
import { fn } from "@/util/fn"
import { Effect, Layer, Scope, Context } from "effect"
import { Config } from "../config/config"
import { Flag } from "../flag/flag"
@@ -10,7 +8,7 @@ import { ShareNext } from "./share-next"
export namespace SessionShare {
export interface Interface {
readonly create: (input?: Parameters<typeof Session.create>[0]) => Effect.Effect<Session.Info>
readonly create: (input?: Session.CreateInput) => Effect.Effect<Session.Info>
readonly share: (sessionID: SessionID) => Effect.Effect<{ url: string }, unknown>
readonly unshare: (sessionID: SessionID) => Effect.Effect<void, unknown>
}
@@ -40,7 +38,7 @@ export namespace SessionShare {
yield* Effect.sync(() => SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } }))
})
const create = Effect.fn("SessionShare.create")(function* (input?: Parameters<typeof Session.create>[0]) {
const create = Effect.fn("SessionShare.create")(function* (input?: Session.CreateInput) {
const result = yield* session.create(input)
if (result.parentID) return result
const conf = yield* cfg.get()
@@ -58,10 +56,4 @@ export namespace SessionShare {
Layer.provide(Session.defaultLayer),
Layer.provide(Config.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export const create = fn(Session.create.schema, (input) => runPromise((svc) => svc.create(input)))
export const share = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.share(sessionID)))
export const unshare = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.unshare(sessionID)))
}
@@ -1,7 +1,6 @@
import path from "path"
import { Effect } from "effect"
import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state"
import type { Tool } from "./tool"
import { Instance } from "../project/instance"
import { AppFileSystem } from "../filesystem"
@@ -22,9 +21,8 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec
if (options?.bypass) return
const ins = yield* InstanceState.context
const full = process.platform === "win32" ? AppFileSystem.normalizePath(target) : target
if (Instance.containsPath(full, ins)) return
if (Instance.containsPath(full)) return
const kind = options?.kind ?? "file"
const dir = kind === "directory" ? full : path.dirname(full)
+42 -24
View File
@@ -1,18 +1,19 @@
import path from "path"
import z from "zod"
import { Effect } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "../filesystem"
import { Search } from "../file/search"
import { assertExternalDirectoryEffect } from "./external-directory"
import DESCRIPTION from "./glob.txt"
import path from "path"
import { Effect, Option } from "effect"
import * as Stream from "effect/Stream"
import { Tool } from "./tool"
import DESCRIPTION from "./glob.txt"
import { Ripgrep } from "../file/ripgrep"
import { Instance } from "../project/instance"
import { assertExternalDirectoryEffect } from "./external-directory"
import { AppFileSystem } from "../filesystem"
export const GlobTool = Tool.define(
"glob",
Effect.gen(function* () {
const rg = yield* Ripgrep.Service
const fs = yield* AppFileSystem.Service
const searchSvc = yield* Search.Service
return {
description: DESCRIPTION,
@@ -27,7 +28,6 @@ export const GlobTool = Tool.define(
}),
execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) =>
Effect.gen(function* () {
const ins = yield* InstanceState.context
yield* ctx.ask({
permission: "glob",
patterns: [params.pattern],
@@ -38,8 +38,8 @@ export const GlobTool = Tool.define(
},
})
let search = params.path ?? ins.directory
search = path.isAbsolute(search) ? search : path.resolve(ins.directory, search)
let search = params.path ?? Instance.directory
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (info?.type === "File") {
throw new Error(`glob path must be a directory: ${search}`)
@@ -47,18 +47,36 @@ export const GlobTool = Tool.define(
yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
const limit = 100
const files = yield* searchSvc.glob({
cwd: search,
pattern: params.pattern,
limit,
signal: ctx.abort,
})
let truncated = false
const files = yield* rg.files({ cwd: search, glob: [params.pattern] }).pipe(
Stream.mapEffect((file) =>
Effect.gen(function* () {
const full = path.resolve(search, file)
const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
const mtime =
info?.mtime.pipe(
Option.map((d) => d.getTime()),
Option.getOrElse(() => 0),
) ?? 0
return { path: full, mtime }
}),
),
Stream.take(limit + 1),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
if (files.length > limit) {
truncated = true
files.length = limit
}
files.sort((a, b) => b.mtime - a.mtime)
const output = []
if (files.files.length === 0) output.push("No files found")
if (files.files.length > 0) {
output.push(...files.files)
if (files.truncated) {
if (files.length === 0) output.push("No files found")
if (files.length > 0) {
output.push(...files.map((f) => f.path))
if (truncated) {
output.push("")
output.push(
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
@@ -67,10 +85,10 @@ export const GlobTool = Tool.define(
}
return {
title: path.relative(ins.worktree, search),
title: path.relative(Instance.worktree, search),
metadata: {
count: files.files.length,
truncated: files.truncated,
count: files.length,
truncated,
},
output: output.join("\n"),
}
+66 -43
View File
@@ -1,12 +1,13 @@
import path from "path"
import z from "zod"
import { Effect } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "../filesystem"
import { Search } from "../file/search"
import { assertExternalDirectoryEffect } from "./external-directory"
import DESCRIPTION from "./grep.txt"
import { Effect, Option } from "effect"
import { Tool } from "./tool"
import { Ripgrep } from "../file/ripgrep"
import { AppFileSystem } from "../filesystem"
import DESCRIPTION from "./grep.txt"
import { Instance } from "../project/instance"
import path from "path"
import { assertExternalDirectoryEffect } from "./external-directory"
const MAX_LINE_LENGTH = 2000
@@ -14,7 +15,7 @@ export const GrepTool = Tool.define(
"grep",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const searchSvc = yield* Search.Service
const rg = yield* Ripgrep.Service
return {
description: DESCRIPTION,
@@ -45,26 +46,25 @@ export const GrepTool = Tool.define(
},
})
const ins = yield* InstanceState.context
const search = AppFileSystem.resolve(
path.isAbsolute(params.path ?? ins.directory)
? (params.path ?? ins.directory)
: path.join(ins.directory, params.path ?? "."),
const searchPath = AppFileSystem.resolve(
path.isAbsolute(params.path ?? Instance.directory)
? (params.path ?? Instance.directory)
: path.join(Instance.directory, params.path ?? "."),
)
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
const cwd = info?.type === "Directory" ? search : path.dirname(search)
const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)]
yield* assertExternalDirectoryEffect(ctx, search, {
const info = yield* fs.stat(searchPath).pipe(Effect.catch(() => Effect.succeed(undefined)))
const cwd = info?.type === "Directory" ? searchPath : path.dirname(searchPath)
const file = info?.type === "Directory" ? undefined : [searchPath]
yield* assertExternalDirectoryEffect(ctx, searchPath, {
kind: info?.type === "Directory" ? "directory" : "file",
})
const result = yield* searchSvc.search({
const result = yield* rg.search({
cwd,
pattern: params.pattern,
glob: params.include ? [params.include] : undefined,
file,
signal: ctx.abort,
})
if (result.items.length === 0) return empty
const rows = result.items.map((item) => ({
@@ -74,50 +74,73 @@ export const GrepTool = Tool.define(
line: item.line_number,
text: item.lines.text,
}))
const times = new Map(
(yield* Effect.forEach(
[...new Set(rows.map((row) => row.path))],
Effect.fnUntraced(function* (file) {
const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!info || info.type === "Directory") return undefined
return [
file,
info.mtime.pipe(
Option.map((time) => time.getTime()),
Option.getOrElse(() => 0),
) ?? 0,
] as const
}),
{ concurrency: 16 },
)).filter((entry): entry is readonly [string, number] => Boolean(entry)),
)
const matches = rows.flatMap((row) => {
const mtime = times.get(row.path)
if (mtime === undefined) return []
return [{ ...row, mtime }]
})
matches.sort((a, b) => b.mtime - a.mtime)
const limit = 100
const truncated = rows.length > limit
const final = truncated ? rows.slice(0, limit) : rows
if (final.length === 0) return empty
const truncated = matches.length > limit
const finalMatches = truncated ? matches.slice(0, limit) : matches
const total = rows.length
const output = [`Found ${total} matches${truncated ? ` (showing first ${limit})` : ""}`]
if (finalMatches.length === 0) return empty
let current = ""
for (const match of final) {
if (current !== match.path) {
if (current !== "") output.push("")
current = match.path
output.push(`${match.path}:`)
const totalMatches = matches.length
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
let currentFile = ""
for (const match of finalMatches) {
if (currentFile !== match.path) {
if (currentFile !== "") {
outputLines.push("")
}
currentFile = match.path
outputLines.push(`${match.path}:`)
}
const text =
const truncatedLineText =
match.text.length > MAX_LINE_LENGTH ? match.text.substring(0, MAX_LINE_LENGTH) + "..." : match.text
output.push(` Line ${match.line}: ${text}`)
outputLines.push(` Line ${match.line}: ${truncatedLineText}`)
}
if (truncated) {
output.push("")
output.push(
`(Results truncated: showing ${limit} of ${total} matches (${total - limit} hidden). Consider using a more specific path or pattern.)`,
outputLines.push("")
outputLines.push(
`(Results truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). Consider using a more specific path or pattern.)`,
)
}
if (result.partial) {
output.push("")
output.push("(Some paths were inaccessible and skipped)")
}
if (result.regexFallbackError) {
output.push("")
output.push(`(Regex fallback: ${result.regexFallbackError})`)
outputLines.push("")
outputLines.push("(Some paths were inaccessible and skipped)")
}
return {
title: params.pattern,
metadata: {
matches: total,
matches: totalMatches,
truncated,
},
output: output.join("\n"),
output: outputLines.join("\n"),
}
}).pipe(Effect.orDie),
}
+45 -33
View File
@@ -1,12 +1,12 @@
import * as path from "path"
import z from "zod"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import { InstanceState } from "@/effect/instance-state"
import { Search } from "../file/search"
import { assertExternalDirectoryEffect } from "./external-directory"
import DESCRIPTION from "./ls.txt"
import { Tool } from "./tool"
import * as path from "path"
import DESCRIPTION from "./ls.txt"
import { Instance } from "../project/instance"
import { Ripgrep } from "../file/ripgrep"
import { assertExternalDirectoryEffect } from "./external-directory"
export const IGNORE_PATTERNS = [
"node_modules/",
@@ -40,7 +40,7 @@ const LIMIT = 100
export const ListTool = Tool.define(
"list",
Effect.gen(function* () {
const searchSvc = yield* Search.Service
const rg = yield* Ripgrep.Service
return {
description: DESCRIPTION,
@@ -53,68 +53,80 @@ export const ListTool = Tool.define(
}),
execute: (params: { path?: string; ignore?: string[] }, ctx: Tool.Context) =>
Effect.gen(function* () {
const ins = yield* InstanceState.context
const search = path.resolve(ins.directory, params.path || ".")
yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
const searchPath = path.resolve(Instance.directory, params.path || ".")
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
yield* ctx.ask({
permission: "list",
patterns: [search],
patterns: [searchPath],
always: ["*"],
metadata: {
path: search,
path: searchPath,
},
})
const glob = IGNORE_PATTERNS.map((item) => `!${item}*`).concat(params.ignore?.map((item) => `!${item}`) || [])
const files = yield* searchSvc.files({ cwd: search, glob, signal: ctx.abort }).pipe(
Stream.take(LIMIT + 1),
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
const files = yield* rg.files({ cwd: searchPath, glob: ignoreGlobs }).pipe(
Stream.take(LIMIT),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
const truncated = files.length > LIMIT
if (truncated) files.length = LIMIT
// Build directory structure
const dirs = new Set<string>()
const map = new Map<string, string[]>()
const filesByDir = new Map<string, string[]>()
for (const file of files) {
const dir = path.dirname(file)
const parts = dir === "." ? [] : dir.split("/")
// Add all parent directories
for (let i = 0; i <= parts.length; i++) {
dirs.add(i === 0 ? "." : parts.slice(0, i).join("/"))
const dirPath = i === 0 ? "." : parts.slice(0, i).join("/")
dirs.add(dirPath)
}
if (!map.has(dir)) map.set(dir, [])
map.get(dir)!.push(path.basename(file))
// Add file to its directory
if (!filesByDir.has(dir)) filesByDir.set(dir, [])
filesByDir.get(dir)!.push(path.basename(file))
}
function render(dir: string, depth: number): string {
function renderDir(dirPath: string, depth: number): string {
const indent = " ".repeat(depth)
let output = ""
if (depth > 0) output += `${indent}${path.basename(dir)}/\n`
const child = " ".repeat(depth + 1)
const dirs2 = Array.from(dirs)
.filter((item) => path.dirname(item) === dir && item !== dir)
if (depth > 0) {
output += `${indent}${path.basename(dirPath)}/\n`
}
const childIndent = " ".repeat(depth + 1)
const children = Array.from(dirs)
.filter((d) => path.dirname(d) === dirPath && d !== dirPath)
.sort()
for (const item of dirs2) {
output += render(item, depth + 1)
// Render subdirectories first
for (const child of children) {
output += renderDir(child, depth + 1)
}
const files = map.get(dir) || []
// Render files
const files = filesByDir.get(dirPath) || []
for (const file of files.sort()) {
output += `${child}${file}\n`
output += `${childIndent}${file}\n`
}
return output
}
const output = `${searchPath}/\n` + renderDir(".", 0)
return {
title: path.relative(ins.worktree, search),
title: path.relative(Instance.worktree, searchPath),
metadata: {
count: files.length,
truncated,
truncated: files.length >= LIMIT,
},
output: `${search}/\n` + render(".", 0),
output,
}
}).pipe(Effect.orDie),
}
-3
View File
@@ -8,7 +8,6 @@ import { Tool } from "./tool"
import { AppFileSystem } from "../filesystem"
import { LSP } from "../lsp"
import { FileTime } from "../file/time"
import { Search } from "../file/search"
import DESCRIPTION from "./read.txt"
import { Instance } from "../project/instance"
import { assertExternalDirectoryEffect } from "./external-directory"
@@ -32,7 +31,6 @@ export const ReadTool = Tool.define(
const fs = yield* AppFileSystem.Service
const instruction = yield* Instruction.Service
const lsp = yield* LSP.Service
const search = yield* Search.Service
const time = yield* FileTime.Service
const scope = yield* Scope.Scope
@@ -78,7 +76,6 @@ export const ReadTool = Tool.define(
})
const warm = Effect.fn("ReadTool.warm")(function* (filepath: string, sessionID: Tool.Context["sessionID"]) {
yield* search.open({ file: filepath }).pipe(Effect.ignore)
yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope))
yield* time.read(sessionID, filepath)
})
+3 -3
View File
@@ -33,7 +33,7 @@ import { Effect, Layer, Context } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { Search } from "../file/search"
import { Ripgrep } from "../file/ripgrep"
import { Format } from "../format"
import { InstanceState } from "@/effect/instance-state"
import { Env } from "../env"
@@ -93,7 +93,7 @@ export namespace ToolRegistry {
| Bus.Service
| HttpClient.HttpClient
| ChildProcessSpawner
| Search.Service
| Ripgrep.Service
| Format.Service
| Truncate.Service
> = Layer.effect(
@@ -344,7 +344,7 @@ export namespace ToolRegistry {
Layer.provide(FetchHttpClient.layer),
Layer.provide(Format.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Search.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Truncate.defaultLayer),
),
)
+8 -7
View File
@@ -2,11 +2,11 @@ import path from "path"
import { pathToFileURL } from "url"
import z from "zod"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import { EffectLogger } from "@/effect/logger"
import { Search } from "../file/search"
import { Skill } from "../skill"
import * as Stream from "effect/Stream"
import { Tool } from "./tool"
import { Skill } from "../skill"
import { Ripgrep } from "../file/ripgrep"
const Parameters = z.object({
name: z.string().describe("The name of the skill from available_skills"),
@@ -16,8 +16,7 @@ export const SkillTool = Tool.define(
"skill",
Effect.gen(function* () {
const skill = yield* Skill.Service
const searchSvc = yield* Search.Service
const rg = yield* Ripgrep.Service
return () =>
Effect.gen(function* () {
const list = yield* skill.available().pipe(Effect.provide(EffectLogger.layer))
@@ -46,9 +45,10 @@ export const SkillTool = Tool.define(
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
const info = yield* skill.get(params.name)
if (!info) {
const all = yield* skill.all()
const available = all.map((item) => item.name).join(", ")
const available = all.map((s) => s.name).join(", ")
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
}
@@ -61,8 +61,9 @@ export const SkillTool = Tool.define(
const dir = path.dirname(info.location)
const base = pathToFileURL(dir).href
const limit = 10
const files = yield* searchSvc.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe(
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe(
Stream.filter((file) => !file.includes("SKILL.md")),
Stream.map((file) => path.resolve(dir, file)),
Stream.take(limit),
+17 -130
View File
@@ -6,21 +6,6 @@ import path from "path"
import { tmpdir } from "../fixture/fixture"
import { Ripgrep } from "../../src/file/ripgrep"
async function seed(dir: string, count: number, size = 16) {
const txt = "a".repeat(size)
await Promise.all(Array.from({ length: count }, (_, i) => Bun.write(path.join(dir, `file-${i}.txt`), `${txt}${i}\n`)))
}
function env(name: string, value: string | undefined) {
const prev = process.env[name]
if (value === undefined) delete process.env[name]
else process.env[name] = value
return () => {
if (prev === undefined) delete process.env[name]
else process.env[name] = prev
}
}
describe("file.ripgrep", () => {
test("defaults to include hidden", async () => {
await using tmp = await tmpdir({
@@ -31,9 +16,11 @@ describe("file.ripgrep", () => {
},
})
const files = await Array.fromAsync(await Ripgrep.files({ cwd: tmp.path }))
expect(files.includes("visible.txt")).toBe(true)
expect(files.includes(path.join(".opencode", "thing.json"))).toBe(true)
const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path }))
const hasVisible = files.includes("visible.txt")
const hasHidden = files.includes(path.join(".opencode", "thing.json"))
expect(hasVisible).toBe(true)
expect(hasHidden).toBe(true)
})
test("hidden false excludes hidden", async () => {
@@ -45,11 +32,15 @@ describe("file.ripgrep", () => {
},
})
const files = await Array.fromAsync(await Ripgrep.files({ cwd: tmp.path, hidden: false }))
expect(files.includes("visible.txt")).toBe(true)
expect(files.includes(path.join(".opencode", "thing.json"))).toBe(false)
const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path, hidden: false }))
const hasVisible = files.includes("visible.txt")
const hasHidden = files.includes(path.join(".opencode", "thing.json"))
expect(hasVisible).toBe(true)
expect(hasHidden).toBe(false)
})
})
describe("Ripgrep.Service", () => {
test("search returns empty when nothing matches", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -57,119 +48,15 @@ describe("file.ripgrep", () => {
},
})
const result = await Ripgrep.search({ cwd: tmp.path, pattern: "needle" })
const result = await Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return yield* rg.search({ cwd: tmp.path, pattern: "needle" })
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
expect(result.partial).toBe(false)
expect(result.items).toEqual([])
})
test("search returns match metadata with normalized path", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, "src"), { recursive: true })
await Bun.write(path.join(dir, "src", "match.ts"), "const needle = 1\n")
},
})
const result = await Ripgrep.search({ cwd: tmp.path, pattern: "needle" })
expect(result.partial).toBe(false)
expect(result.items).toHaveLength(1)
expect(result.items[0]?.path.text).toBe(path.join("src", "match.ts"))
expect(result.items[0]?.line_number).toBe(1)
expect(result.items[0]?.lines.text).toContain("needle")
})
test("files returns empty when glob matches no files in worker mode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, "packages", "console"), { recursive: true })
await Bun.write(path.join(dir, "packages", "console", "package.json"), "{}")
},
})
const ctl = new AbortController()
const files = await Array.fromAsync(
await Ripgrep.files({
cwd: tmp.path,
glob: ["packages/*"],
signal: ctl.signal,
}),
)
expect(files).toEqual([])
})
test("ignores RIPGREP_CONFIG_PATH in direct mode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n")
},
})
const restore = env("RIPGREP_CONFIG_PATH", path.join(tmp.path, "missing-ripgreprc"))
try {
const result = await Ripgrep.search({ cwd: tmp.path, pattern: "needle" })
expect(result.items).toHaveLength(1)
} finally {
restore()
}
})
test("ignores RIPGREP_CONFIG_PATH in worker mode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n")
},
})
const restore = env("RIPGREP_CONFIG_PATH", path.join(tmp.path, "missing-ripgreprc"))
try {
const ctl = new AbortController()
const result = await Ripgrep.search({
cwd: tmp.path,
pattern: "needle",
signal: ctl.signal,
})
expect(result.items).toHaveLength(1)
} finally {
restore()
}
})
test("aborts files scan in worker mode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await seed(dir, 4000)
},
})
const ctl = new AbortController()
const iter = await Ripgrep.files({ cwd: tmp.path, signal: ctl.signal })
const pending = Array.fromAsync(iter)
setTimeout(() => ctl.abort(), 0)
const err = await pending.catch((err) => err)
expect(err).toBeInstanceOf(Error)
expect(err.name).toBe("AbortError")
}, 15_000)
test("aborts search in worker mode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await seed(dir, 512, 64 * 1024)
},
})
const ctl = new AbortController()
const pending = Ripgrep.search({ cwd: tmp.path, pattern: "needle", signal: ctl.signal })
setTimeout(() => ctl.abort(), 0)
const err = await pending.catch((err) => err)
expect(err).toBeInstanceOf(Error)
expect(err.name).toBe("AbortError")
}, 15_000)
})
describe("Ripgrep.Service", () => {
test("search returns matched rows", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -8,9 +8,19 @@ import { SessionID } from "../../src/session/schema"
import { Log } from "../../src/util/log"
import { $ } from "bun"
import { tmpdir } from "../fixture/fixture"
import { Effect } from "effect"
Log.init({ print: false })
function run<A>(fn: (svc: Project.Interface) => Effect.Effect<A>) {
return Effect.runPromise(
Effect.gen(function* () {
const svc = yield* Project.Service
return yield* fn(svc)
}).pipe(Effect.provide(Project.defaultLayer)),
)
}
function uid() {
return SessionID.make(crypto.randomUUID())
}
@@ -58,7 +68,7 @@ describe("migrateFromGlobal", () => {
await $`git config user.name "Test"`.cwd(tmp.path).quiet()
await $`git config user.email "test@opencode.test"`.cwd(tmp.path).quiet()
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
const { project: pre } = await Project.fromDirectory(tmp.path)
const { project: pre } = await run((svc) => svc.fromDirectory(tmp.path))
expect(pre.id).toBe(ProjectID.global)
// 2. Seed a session under "global" with matching directory
@@ -68,7 +78,7 @@ describe("migrateFromGlobal", () => {
// 3. Make a commit so the project gets a real ID
await $`git commit --allow-empty -m "root"`.cwd(tmp.path).quiet()
const { project: real } = await Project.fromDirectory(tmp.path)
const { project: real } = await run((svc) => svc.fromDirectory(tmp.path))
expect(real.id).not.toBe(ProjectID.global)
// 4. The session should have been migrated to the real project ID
@@ -80,7 +90,7 @@ describe("migrateFromGlobal", () => {
test("migrates global sessions even when project row already exists", async () => {
// 1. Create a repo with a commit — real project ID created immediately
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.id).not.toBe(ProjectID.global)
// 2. Ensure "global" project row exists (as it would from a prior no-git session)
@@ -94,7 +104,7 @@ describe("migrateFromGlobal", () => {
// 4. Call fromDirectory again — project row already exists,
// so the current code skips migration entirely. This is the bug.
await Project.fromDirectory(tmp.path)
await run((svc) => svc.fromDirectory(tmp.path))
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined()
@@ -103,7 +113,7 @@ describe("migrateFromGlobal", () => {
test("does not claim sessions with empty directory", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.id).not.toBe(ProjectID.global)
ensureGlobal()
@@ -113,7 +123,7 @@ describe("migrateFromGlobal", () => {
const id = uid()
seed({ id, dir: "", project: ProjectID.global })
await Project.fromDirectory(tmp.path)
await run((svc) => svc.fromDirectory(tmp.path))
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined()
@@ -122,7 +132,7 @@ describe("migrateFromGlobal", () => {
test("does not steal sessions from unrelated directories", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.id).not.toBe(ProjectID.global)
ensureGlobal()
@@ -131,7 +141,7 @@ describe("migrateFromGlobal", () => {
const id = uid()
seed({ id, dir: "/some/other/dir", project: ProjectID.global })
await Project.fromDirectory(tmp.path)
await run((svc) => svc.fromDirectory(tmp.path))
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
expect(row).toBeDefined()
+83 -69
View File
@@ -8,7 +8,7 @@ import { GlobalBus } from "../../src/bus/global"
import { ProjectID } from "../../src/project/schema"
import { Effect, Layer, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "../../src/filesystem"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
@@ -16,6 +16,15 @@ Log.init({ print: false })
const encoder = new TextEncoder()
function run<A>(fn: (svc: Project.Interface) => Effect.Effect<A>, layer = Project.defaultLayer) {
return Effect.runPromise(
Effect.gen(function* () {
const svc = yield* Project.Service
return yield* fn(svc)
}).pipe(Effect.provide(layer)),
)
}
/**
* Creates a mock ChildProcessSpawner layer that intercepts git subcommands
* matching `failArg` and returns exit code 128, while delegating everything
@@ -64,7 +73,7 @@ describe("Project.fromDirectory", () => {
await using tmp = await tmpdir()
await $`git init`.cwd(tmp.path).quiet()
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project).toBeDefined()
expect(project.id).toBe(ProjectID.global)
@@ -78,7 +87,7 @@ describe("Project.fromDirectory", () => {
test("should handle git repository with commits", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project).toBeDefined()
expect(project.id).not.toBe(ProjectID.global)
@@ -91,14 +100,14 @@ describe("Project.fromDirectory", () => {
test("returns global for non-git directory", async () => {
await using tmp = await tmpdir()
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.id).toBe(ProjectID.global)
})
test("derives stable project ID from root commit", async () => {
await using tmp = await tmpdir({ git: true })
const { project: a } = await Project.fromDirectory(tmp.path)
const { project: b } = await Project.fromDirectory(tmp.path)
const { project: a } = await run((svc) => svc.fromDirectory(tmp.path))
const { project: b } = await run((svc) => svc.fromDirectory(tmp.path))
expect(b.id).toBe(a.id)
})
})
@@ -109,7 +118,7 @@ describe("Project.fromDirectory git failure paths", () => {
await $`git init`.cwd(tmp.path).quiet()
// rev-list fails because HEAD doesn't exist yet — this is the natural scenario
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.vcs).toBe("git")
expect(project.id).toBe(ProjectID.global)
expect(project.worktree).toBe(tmp.path)
@@ -119,9 +128,7 @@ describe("Project.fromDirectory git failure paths", () => {
await using tmp = await tmpdir({ git: true })
const layer = projectLayerWithFailure("--show-toplevel")
const { project, sandbox } = await Effect.runPromise(
Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)),
)
const { project, sandbox } = await run((svc) => svc.fromDirectory(tmp.path), layer)
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path)
})
@@ -130,9 +137,7 @@ describe("Project.fromDirectory git failure paths", () => {
await using tmp = await tmpdir({ git: true })
const layer = projectLayerWithFailure("--git-common-dir")
const { project, sandbox } = await Effect.runPromise(
Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)),
)
const { project, sandbox } = await run((svc) => svc.fromDirectory(tmp.path), layer)
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path)
})
@@ -142,7 +147,7 @@ describe("Project.fromDirectory with worktrees", () => {
test("should set worktree to root when called from root", async () => {
await using tmp = await tmpdir({ git: true })
const { project, sandbox } = await Project.fromDirectory(tmp.path)
const { project, sandbox } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path)
@@ -156,7 +161,7 @@ describe("Project.fromDirectory with worktrees", () => {
try {
await $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp.path).quiet()
const { project, sandbox } = await Project.fromDirectory(worktreePath)
const { project, sandbox } = await run((svc) => svc.fromDirectory(worktreePath))
expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(worktreePath)
@@ -173,13 +178,13 @@ describe("Project.fromDirectory with worktrees", () => {
test("worktree should share project ID with main repo", async () => {
await using tmp = await tmpdir({ git: true })
const { project: main } = await Project.fromDirectory(tmp.path)
const { project: main } = await run((svc) => svc.fromDirectory(tmp.path))
const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt-shared")
try {
await $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp.path).quiet()
const { project: wt } = await Project.fromDirectory(worktreePath)
const { project: wt } = await run((svc) => svc.fromDirectory(worktreePath))
expect(wt.id).toBe(main.id)
@@ -205,8 +210,8 @@ describe("Project.fromDirectory with worktrees", () => {
await $`git clone --bare ${tmp.path} ${bare}`.quiet()
await $`git clone ${bare} ${clone}`.quiet()
const { project: a } = await Project.fromDirectory(tmp.path)
const { project: b } = await Project.fromDirectory(clone)
const { project: a } = await run((svc) => svc.fromDirectory(tmp.path))
const { project: b } = await run((svc) => svc.fromDirectory(clone))
expect(b.id).toBe(a.id)
} finally {
@@ -223,8 +228,8 @@ describe("Project.fromDirectory with worktrees", () => {
await $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp.path).quiet()
await $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp.path).quiet()
await Project.fromDirectory(worktree1)
const { project } = await Project.fromDirectory(worktree2)
await run((svc) => svc.fromDirectory(worktree1))
const { project } = await run((svc) => svc.fromDirectory(worktree2))
expect(project.worktree).toBe(tmp.path)
expect(project.sandboxes).toContain(worktree1)
@@ -246,12 +251,12 @@ describe("Project.fromDirectory with worktrees", () => {
describe("Project.discover", () => {
test("should discover favicon.png in root", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
await Bun.write(path.join(tmp.path, "favicon.png"), pngData)
await Project.discover(project)
await run((svc) => svc.discover(project))
const updated = Project.get(project.id)
expect(updated).toBeDefined()
@@ -263,11 +268,11 @@ describe("Project.discover", () => {
test("should not discover non-image files", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
await Bun.write(path.join(tmp.path, "favicon.txt"), "not an image")
await Project.discover(project)
await run((svc) => svc.discover(project))
const updated = Project.get(project.id)
expect(updated).toBeDefined()
@@ -278,12 +283,14 @@ describe("Project.discover", () => {
describe("Project.update", () => {
test("should update name", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const updated = await Project.update({
projectID: project.id,
name: "New Project Name",
})
const updated = await run((svc) =>
svc.update({
projectID: project.id,
name: "New Project Name",
}),
)
expect(updated.name).toBe("New Project Name")
@@ -293,12 +300,14 @@ describe("Project.update", () => {
test("should update icon url", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const updated = await Project.update({
projectID: project.id,
icon: { url: "https://example.com/icon.png" },
})
const updated = await run((svc) =>
svc.update({
projectID: project.id,
icon: { url: "https://example.com/icon.png" },
}),
)
expect(updated.icon?.url).toBe("https://example.com/icon.png")
@@ -308,12 +317,14 @@ describe("Project.update", () => {
test("should update icon color", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const updated = await Project.update({
projectID: project.id,
icon: { color: "#ff0000" },
})
const updated = await run((svc) =>
svc.update({
projectID: project.id,
icon: { color: "#ff0000" },
}),
)
expect(updated.icon?.color).toBe("#ff0000")
@@ -323,12 +334,14 @@ describe("Project.update", () => {
test("should update commands", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const updated = await Project.update({
projectID: project.id,
commands: { start: "npm run dev" },
})
const updated = await run((svc) =>
svc.update({
projectID: project.id,
commands: { start: "npm run dev" },
}),
)
expect(updated.commands?.start).toBe("npm run dev")
@@ -338,16 +351,18 @@ describe("Project.update", () => {
test("should throw error when project not found", async () => {
await expect(
Project.update({
projectID: ProjectID.make("nonexistent-project-id"),
name: "Should Fail",
}),
run((svc) =>
svc.update({
projectID: ProjectID.make("nonexistent-project-id"),
name: "Should Fail",
}),
),
).rejects.toThrow("Project not found: nonexistent-project-id")
})
test("should emit GlobalBus event on update", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
let eventPayload: any = null
const on = (data: any) => {
@@ -356,10 +371,7 @@ describe("Project.update", () => {
GlobalBus.on("event", on)
try {
await Project.update({
projectID: project.id,
name: "Updated Name",
})
await run((svc) => svc.update({ projectID: project.id, name: "Updated Name" }))
expect(eventPayload).not.toBeNull()
expect(eventPayload.payload.type).toBe("project.updated")
@@ -371,14 +383,16 @@ describe("Project.update", () => {
test("should update multiple fields at once", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const updated = await Project.update({
projectID: project.id,
name: "Multi Update",
icon: { url: "https://example.com/favicon.ico", color: "#00ff00" },
commands: { start: "make start" },
})
const updated = await run((svc) =>
svc.update({
projectID: project.id,
name: "Multi Update",
icon: { url: "https://example.com/favicon.ico", color: "#00ff00" },
commands: { start: "make start" },
}),
)
expect(updated.name).toBe("Multi Update")
expect(updated.icon?.url).toBe("https://example.com/favicon.ico")
@@ -390,7 +404,7 @@ describe("Project.update", () => {
describe("Project.list and Project.get", () => {
test("list returns all projects", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const all = Project.list()
expect(all.length).toBeGreaterThan(0)
@@ -399,7 +413,7 @@ describe("Project.list and Project.get", () => {
test("get returns project by id", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const found = Project.get(project.id)
expect(found).toBeDefined()
@@ -415,7 +429,7 @@ describe("Project.list and Project.get", () => {
describe("Project.setInitialized", () => {
test("sets time_initialized on project", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
expect(project.time.initialized).toBeUndefined()
@@ -429,15 +443,15 @@ describe("Project.setInitialized", () => {
describe("Project.addSandbox and Project.removeSandbox", () => {
test("addSandbox adds directory and removeSandbox removes it", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const sandboxDir = path.join(tmp.path, "sandbox-test")
await Project.addSandbox(project.id, sandboxDir)
await run((svc) => svc.addSandbox(project.id, sandboxDir))
let found = Project.get(project.id)
expect(found?.sandboxes).toContain(sandboxDir)
await Project.removeSandbox(project.id, sandboxDir)
await run((svc) => svc.removeSandbox(project.id, sandboxDir))
found = Project.get(project.id)
expect(found?.sandboxes).not.toContain(sandboxDir)
@@ -445,14 +459,14 @@ describe("Project.addSandbox and Project.removeSandbox", () => {
test("addSandbox emits GlobalBus event", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
const sandboxDir = path.join(tmp.path, "sandbox-event")
const events: any[] = []
const on = (evt: any) => events.push(evt)
GlobalBus.on("event", on)
await Project.addSandbox(project.id, sandboxDir)
await run((svc) => svc.addSandbox(project.id, sandboxDir))
GlobalBus.off("event", on)
expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true)
@@ -1,27 +1,43 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import z from "zod"
import { Instance } from "../../src/project/instance"
import { Project } from "../../src/project/project"
import { Session } from "../../src/session"
import { Session as SessionNs } from "../../src/session"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
describe("Session.listGlobal", () => {
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
}
const svc = {
...SessionNs,
create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input)))
},
setArchived(input: z.output<typeof SessionNs.SetArchivedInput>) {
return run(SessionNs.Service.use((svc) => svc.setArchived(input)))
},
}
describe("session.listGlobal", () => {
test("lists sessions across projects with project metadata", async () => {
await using first = await tmpdir({ git: true })
await using second = await tmpdir({ git: true })
const firstSession = await Instance.provide({
directory: first.path,
fn: async () => Session.create({ title: "first-session" }),
fn: async () => svc.create({ title: "first-session" }),
})
const secondSession = await Instance.provide({
directory: second.path,
fn: async () => Session.create({ title: "second-session" }),
fn: async () => svc.create({ title: "second-session" }),
})
const sessions = [...Session.listGlobal({ limit: 200 })]
const sessions = [...svc.listGlobal({ limit: 200 })]
const ids = sessions.map((session) => session.id)
expect(ids).toContain(firstSession.id)
@@ -44,20 +60,20 @@ describe("Session.listGlobal", () => {
const archived = await Instance.provide({
directory: tmp.path,
fn: async () => Session.create({ title: "archived-session" }),
fn: async () => svc.create({ title: "archived-session" }),
})
await Instance.provide({
directory: tmp.path,
fn: async () => Session.setArchived({ sessionID: archived.id, time: Date.now() }),
fn: async () => svc.setArchived({ sessionID: archived.id, time: Date.now() }),
})
const sessions = [...Session.listGlobal({ limit: 200 })]
const sessions = [...svc.listGlobal({ limit: 200 })]
const ids = sessions.map((session) => session.id)
expect(ids).not.toContain(archived.id)
const allSessions = [...Session.listGlobal({ limit: 200, archived: true })]
const allSessions = [...svc.listGlobal({ limit: 200, archived: true })]
const allIds = allSessions.map((session) => session.id)
expect(allIds).toContain(archived.id)
@@ -68,19 +84,19 @@ describe("Session.listGlobal", () => {
const first = await Instance.provide({
directory: tmp.path,
fn: async () => Session.create({ title: "page-one" }),
fn: async () => svc.create({ title: "page-one" }),
})
await new Promise((resolve) => setTimeout(resolve, 5))
const second = await Instance.provide({
directory: tmp.path,
fn: async () => Session.create({ title: "page-two" }),
fn: async () => svc.create({ title: "page-two" }),
})
const page = [...Session.listGlobal({ directory: tmp.path, limit: 1 })]
const page = [...svc.listGlobal({ directory: tmp.path, limit: 1 })]
expect(page.length).toBe(1)
expect(page[0].id).toBe(second.id)
const next = [...Session.listGlobal({ directory: tmp.path, limit: 10, cursor: page[0].time.updated })]
const next = [...svc.listGlobal({ directory: tmp.path, limit: 10, cursor: page[0].time.updated })]
const ids = next.map((session) => session.id)
expect(ids).toContain(first.id)
@@ -1,36 +1,48 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { afterEach, describe, expect, mock, test } from "bun:test"
import { Effect } from "effect"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { Session } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt"
import { Session as SessionNs } from "../../src/session"
import type { SessionID } from "../../src/session/schema"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
}
const svc = {
...SessionNs,
create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input)))
},
remove(id: SessionID) {
return run(SessionNs.Service.use((svc) => svc.remove(id)))
},
}
afterEach(async () => {
mock.restore()
await Instance.disposeAll()
})
describe("session action routes", () => {
test("abort route calls SessionPrompt.cancel", async () => {
test("abort route returns success", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const cancel = spyOn(SessionPrompt, "cancel").mockResolvedValue()
const session = await svc.create({})
const app = Server.Default().app
const res = await app.request(`/session/${session.id}/abort`, { method: "POST" })
expect(res.status).toBe(200)
expect(await res.json()).toBe(true)
expect(cancel).toHaveBeenCalledWith(session.id)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -1,30 +1,42 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
import { Session as SessionNs } from "../../src/session"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
}
const svc = {
...SessionNs,
create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input)))
},
}
afterEach(async () => {
await Instance.disposeAll()
})
describe("Session.list", () => {
describe("session.list", () => {
test("filters by directory", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const first = await Session.create({})
const first = await svc.create({})
await using other = await tmpdir({ git: true })
const second = await Instance.provide({
directory: other.path,
fn: async () => Session.create({}),
fn: async () => svc.create({}),
})
const sessions = [...Session.list({ directory: tmp.path })]
const sessions = [...svc.list({ directory: tmp.path })]
const ids = sessions.map((s) => s.id)
expect(ids).toContain(first.id)
@@ -38,10 +50,10 @@ describe("Session.list", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const root = await Session.create({ title: "root-session" })
const child = await Session.create({ title: "child-session", parentID: root.id })
const root = await svc.create({ title: "root-session" })
const child = await svc.create({ title: "child-session", parentID: root.id })
const sessions = [...Session.list({ roots: true })]
const sessions = [...svc.list({ roots: true })]
const ids = sessions.map((s) => s.id)
expect(ids).toContain(root.id)
@@ -55,10 +67,10 @@ describe("Session.list", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({ title: "new-session" })
const session = await svc.create({ title: "new-session" })
const futureStart = Date.now() + 86400000
const sessions = [...Session.list({ start: futureStart })]
const sessions = [...svc.list({ start: futureStart })]
expect(sessions.length).toBe(0)
},
})
@@ -69,10 +81,10 @@ describe("Session.list", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Session.create({ title: "unique-search-term-abc" })
await Session.create({ title: "other-session-xyz" })
await svc.create({ title: "unique-search-term-abc" })
await svc.create({ title: "other-session-xyz" })
const sessions = [...Session.list({ search: "unique-search" })]
const sessions = [...svc.list({ search: "unique-search" })]
const titles = sessions.map((s) => s.title)
expect(titles).toContain("unique-search-term-abc")
@@ -86,11 +98,11 @@ describe("Session.list", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await Session.create({ title: "session-1" })
await Session.create({ title: "session-2" })
await Session.create({ title: "session-3" })
await svc.create({ title: "session-1" })
await svc.create({ title: "session-2" })
await svc.create({ title: "session-3" })
const sessions = [...Session.list({ limit: 2 })]
const sessions = [...svc.list({ limit: 2 })]
expect(sessions.length).toBe(2)
},
})
@@ -1,7 +1,8 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { Session } from "../../src/session"
import { Session as SessionNs } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { Log } from "../../src/util/log"
@@ -9,6 +10,26 @@ import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
}
const svc = {
...SessionNs,
create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input)))
},
remove(id: SessionID) {
return run(SessionNs.Service.use((svc) => svc.remove(id)))
},
updateMessage<T extends MessageV2.Info>(msg: T) {
return run(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
},
updatePart<T extends MessageV2.Part>(part: T) {
return run(SessionNs.Service.use((svc) => svc.updatePart(part)))
},
}
afterEach(async () => {
await Instance.disposeAll()
})
@@ -30,7 +51,7 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D
for (let i = 0; i < count; i++) {
const id = MessageID.ascending()
ids.push(id)
await Session.updateMessage({
await svc.updateMessage({
id,
sessionID,
role: "user",
@@ -40,7 +61,7 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D
tools: {},
mode: "",
} as unknown as MessageV2.Info)
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID,
messageID: id,
@@ -58,7 +79,7 @@ describe("session messages endpoint", () => {
Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 5)
const app = Server.Default().app
@@ -75,7 +96,7 @@ describe("session messages endpoint", () => {
const bBody = (await b.json()) as MessageV2.WithParts[]
expect(bBody.map((item) => item.info.id)).toEqual(ids.slice(-4, -2))
await Session.remove(session.id)
await svc.remove(session.id)
},
}),
)
@@ -87,7 +108,7 @@ describe("session messages endpoint", () => {
Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 3)
const app = Server.Default().app
@@ -96,7 +117,7 @@ describe("session messages endpoint", () => {
const body = (await res.json()) as MessageV2.WithParts[]
expect(body.map((item) => item.info.id)).toEqual(ids)
await Session.remove(session.id)
await svc.remove(session.id)
},
}),
)
@@ -108,7 +129,7 @@ describe("session messages endpoint", () => {
Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const app = Server.Default().app
const bad = await app.request(`/session/${session.id}/message?limit=2&before=bad`)
@@ -117,7 +138,7 @@ describe("session messages endpoint", () => {
const miss = await app.request(`/session/ses_missing/message?limit=2`)
expect(miss.status).toBe(404)
await Session.remove(session.id)
await svc.remove(session.id)
},
}),
)
@@ -129,7 +150,7 @@ describe("session messages endpoint", () => {
Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
await fill(session.id, 520)
const app = Server.Default().app
@@ -138,7 +159,7 @@ describe("session messages endpoint", () => {
const body = (await res.json()) as MessageV2.WithParts[]
expect(body).toHaveLength(510)
await Session.remove(session.id)
await svc.remove(session.id)
},
}),
)
@@ -1,5 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Session } from "../../src/session"
import { Effect } from "effect"
import { Session as SessionNs } from "../../src/session"
import type { SessionID } from "../../src/session/schema"
import { Log } from "../../src/util/log"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
@@ -7,6 +9,20 @@ import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
}
const svc = {
...SessionNs,
create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input)))
},
remove(id: SessionID) {
return run(SessionNs.Service.use((svc) => svc.remove(id)))
},
}
afterEach(async () => {
await Instance.disposeAll()
})
@@ -18,7 +34,7 @@ describe("tui.selectSession endpoint", () => {
directory: tmp.path,
fn: async () => {
// #given
const session = await Session.create({})
const session = await svc.create({})
// #when
const app = Server.Default().app
@@ -33,7 +49,7 @@ describe("tui.selectSession endpoint", () => {
const body = await response.json()
expect(body).toBe(true)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -3,6 +3,7 @@ import { APICallError } from "ai"
import { Cause, Effect, Exit, Layer, ManagedRuntime } from "effect"
import * as Stream from "effect/Stream"
import path from "path"
import z from "zod"
import { Bus } from "../../src/bus"
import { Config } from "../../src/config/config"
import { Agent } from "../../src/agent/agent"
@@ -14,7 +15,7 @@ import { Log } from "../../src/util/log"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { provideTmpdirInstance, tmpdir } from "../fixture/fixture"
import { Session } from "../../src/session"
import { Session as SessionNs } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
@@ -29,6 +30,26 @@ import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
Log.init({ print: false })
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
}
const svc = {
...SessionNs,
create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input)))
},
messages(input: z.output<typeof SessionNs.MessagesInput>) {
return run(SessionNs.Service.use((svc) => svc.messages(input)))
},
updateMessage<T extends MessageV2.Info>(msg: T) {
return run(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
},
updatePart<T extends MessageV2.Part>(part: T) {
return run(SessionNs.Service.use((svc) => svc.updatePart(part)))
},
}
const summary = Layer.succeed(
SessionSummary.Service,
SessionSummary.Service.of({
@@ -80,7 +101,7 @@ function createModel(opts: {
const wide = () => ProviderTest.fake({ model: createModel({ context: 100_000, output: 32_000 }) })
async function user(sessionID: SessionID, text: string) {
const msg = await Session.updateMessage({
const msg = await svc.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID,
@@ -88,7 +109,7 @@ async function user(sessionID: SessionID, text: string) {
model: ref,
time: { created: Date.now() },
})
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID,
@@ -119,12 +140,12 @@ async function assistant(sessionID: SessionID, parentID: MessageID, root: string
time: { created: Date.now() },
finish: "end_turn",
}
await Session.updateMessage(msg)
await svc.updateMessage(msg)
return msg
}
async function tool(sessionID: SessionID, messageID: MessageID, tool: string, output: string) {
return Session.updatePart({
return svc.updatePart({
id: PartID.ascending(),
messageID,
sessionID,
@@ -171,7 +192,7 @@ function runtime(result: "continue" | "compact", plugin = Plugin.defaultLayer, p
return ManagedRuntime.make(
Layer.mergeAll(SessionCompaction.layer, bus).pipe(
Layer.provide(provider.layer),
Layer.provide(Session.defaultLayer),
Layer.provide(SessionNs.defaultLayer),
Layer.provide(layer(result)),
Layer.provide(Agent.defaultLayer),
Layer.provide(plugin),
@@ -191,9 +212,9 @@ const deps = Layer.mergeAll(
)
const env = Layer.mergeAll(
Session.defaultLayer,
SessionNs.defaultLayer,
CrossSpawnSpawner.defaultLayer,
SessionCompaction.layer.pipe(Layer.provide(Session.defaultLayer), Layer.provideMerge(deps)),
SessionCompaction.layer.pipe(Layer.provide(SessionNs.defaultLayer), Layer.provideMerge(deps)),
)
const it = testEffect(env)
@@ -227,7 +248,7 @@ function liveRuntime(layer: Layer.Layer<LLM.Service>, provider = ProviderTest.fa
return ManagedRuntime.make(
Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe(
Layer.provide(provider.layer),
Layer.provide(Session.defaultLayer),
Layer.provide(SessionNs.defaultLayer),
Layer.provide(Snapshot.defaultLayer),
Layer.provide(layer),
Layer.provide(Permission.defaultLayer),
@@ -467,9 +488,9 @@ describe("session.compaction.create", () => {
provideTmpdirInstance(() =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const session = yield* Session.Service
const ssn = yield* SessionNs.Service
const info = yield* session.create({})
const info = yield* ssn.create({})
yield* compact.create({
sessionID: info.id,
@@ -479,7 +500,7 @@ describe("session.compaction.create", () => {
overflow: true,
})
const msgs = yield* session.messages({ sessionID: info.id })
const msgs = yield* ssn.messages({ sessionID: info.id })
expect(msgs).toHaveLength(1)
expect(msgs[0].info.role).toBe("user")
expect(msgs[0].parts).toHaveLength(1)
@@ -499,9 +520,9 @@ describe("session.compaction.prune", () => {
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const session = yield* Session.Service
const info = yield* session.create({})
const a = yield* session.updateMessage({
const ssn = yield* SessionNs.Service
const info = yield* ssn.create({})
const a = yield* ssn.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
@@ -509,7 +530,7 @@ describe("session.compaction.prune", () => {
model: ref,
time: { created: Date.now() },
})
yield* session.updatePart({
yield* ssn.updatePart({
id: PartID.ascending(),
messageID: a.id,
sessionID: info.id,
@@ -536,8 +557,8 @@ describe("session.compaction.prune", () => {
time: { created: Date.now() },
finish: "end_turn",
}
yield* session.updateMessage(b)
yield* session.updatePart({
yield* ssn.updateMessage(b)
yield* ssn.updatePart({
id: PartID.ascending(),
messageID: b.id,
sessionID: info.id,
@@ -554,7 +575,7 @@ describe("session.compaction.prune", () => {
},
})
for (const text of ["second", "third"]) {
const msg = yield* session.updateMessage({
const msg = yield* ssn.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
@@ -562,7 +583,7 @@ describe("session.compaction.prune", () => {
model: ref,
time: { created: Date.now() },
})
yield* session.updatePart({
yield* ssn.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID: info.id,
@@ -573,7 +594,7 @@ describe("session.compaction.prune", () => {
yield* compact.prune({ sessionID: info.id })
const msgs = yield* session.messages({ 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")
@@ -589,9 +610,9 @@ describe("session.compaction.prune", () => {
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const compact = yield* SessionCompaction.Service
const session = yield* Session.Service
const info = yield* session.create({})
const a = yield* session.updateMessage({
const ssn = yield* SessionNs.Service
const info = yield* ssn.create({})
const a = yield* ssn.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
@@ -599,7 +620,7 @@ describe("session.compaction.prune", () => {
model: ref,
time: { created: Date.now() },
})
yield* session.updatePart({
yield* ssn.updatePart({
id: PartID.ascending(),
messageID: a.id,
sessionID: info.id,
@@ -626,8 +647,8 @@ describe("session.compaction.prune", () => {
time: { created: Date.now() },
finish: "end_turn",
}
yield* session.updateMessage(b)
yield* session.updatePart({
yield* ssn.updateMessage(b)
yield* ssn.updatePart({
id: PartID.ascending(),
messageID: b.id,
sessionID: info.id,
@@ -644,7 +665,7 @@ describe("session.compaction.prune", () => {
},
})
for (const text of ["second", "third"]) {
const msg = yield* session.updateMessage({
const msg = yield* ssn.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
@@ -652,7 +673,7 @@ describe("session.compaction.prune", () => {
model: ref,
time: { created: Date.now() },
})
yield* session.updatePart({
yield* ssn.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID: info.id,
@@ -663,7 +684,7 @@ describe("session.compaction.prune", () => {
yield* compact.prune({ sessionID: info.id })
const msgs = yield* session.messages({ 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")
if (part?.type === "tool" && part.state.status === "completed") {
@@ -680,12 +701,12 @@ describe("session.compaction.process", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const msg = await user(session.id, "hello")
const reply = await assistant(session.id, msg.id, tmp.path)
const rt = runtime("continue")
try {
const msgs = await Session.messages({ sessionID: session.id })
const msgs = await svc.messages({ sessionID: session.id })
await expect(
rt.runPromise(
SessionCompaction.Service.use((svc) =>
@@ -710,9 +731,9 @@ describe("session.compaction.process", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const msg = await user(session.id, "hello")
const msgs = await Session.messages({ sessionID: session.id })
const msgs = await svc.messages({ sessionID: session.id })
const done = defer()
let seen = false
const rt = runtime("continue", Plugin.defaultLayer, wide())
@@ -760,11 +781,11 @@ describe("session.compaction.process", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const msg = await user(session.id, "hello")
const rt = runtime("compact", Plugin.defaultLayer, wide())
try {
const msgs = await Session.messages({ sessionID: session.id })
const msgs = await svc.messages({ sessionID: session.id })
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
@@ -776,7 +797,7 @@ describe("session.compaction.process", () => {
),
)
const summary = (await Session.messages({ sessionID: session.id })).find(
const summary = (await svc.messages({ sessionID: session.id })).find(
(msg) => msg.info.role === "assistant" && msg.info.summary,
)
@@ -798,11 +819,11 @@ describe("session.compaction.process", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const msg = await user(session.id, "hello")
const rt = runtime("continue", Plugin.defaultLayer, wide())
try {
const msgs = await Session.messages({ sessionID: session.id })
const msgs = await svc.messages({ sessionID: session.id })
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
@@ -814,7 +835,7 @@ describe("session.compaction.process", () => {
),
)
const all = await Session.messages({ sessionID: session.id })
const all = await svc.messages({ sessionID: session.id })
const last = all.at(-1)
expect(result).toBe("continue")
@@ -838,11 +859,11 @@ describe("session.compaction.process", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const msg = await user(session.id, "hello")
const rt = runtime("continue", autocontinue(false), wide())
try {
const msgs = await Session.messages({ sessionID: session.id })
const msgs = await svc.messages({ sessionID: session.id })
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
@@ -854,7 +875,7 @@ describe("session.compaction.process", () => {
),
)
const all = await Session.messages({ sessionID: session.id })
const all = await svc.messages({ sessionID: session.id })
const last = all.at(-1)
expect(result).toBe("continue")
@@ -881,10 +902,10 @@ describe("session.compaction.process", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
await user(session.id, "root")
const replay = await user(session.id, "image")
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
messageID: replay.id,
sessionID: session.id,
@@ -896,7 +917,7 @@ describe("session.compaction.process", () => {
const msg = await user(session.id, "current")
const rt = runtime("continue", Plugin.defaultLayer, wide())
try {
const msgs = await Session.messages({ sessionID: session.id })
const msgs = await svc.messages({ sessionID: session.id })
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
@@ -909,7 +930,7 @@ describe("session.compaction.process", () => {
),
)
const last = (await Session.messages({ sessionID: session.id })).at(-1)
const last = (await svc.messages({ sessionID: session.id })).at(-1)
expect(result).toBe("continue")
expect(last?.info.role).toBe("user")
@@ -929,13 +950,13 @@ describe("session.compaction.process", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
await user(session.id, "earlier")
const msg = await user(session.id, "current")
const rt = runtime("continue", Plugin.defaultLayer, wide())
try {
const msgs = await Session.messages({ sessionID: session.id })
const msgs = await svc.messages({ sessionID: session.id })
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
@@ -948,7 +969,7 @@ describe("session.compaction.process", () => {
),
)
const last = (await Session.messages({ sessionID: session.id })).at(-1)
const last = (await svc.messages({ sessionID: session.id })).at(-1)
expect(result).toBe("continue")
expect(last?.info.role).toBe("user")
@@ -989,9 +1010,9 @@ describe("session.compaction.process", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const msg = await user(session.id, "hello")
const msgs = await Session.messages({ sessionID: session.id })
const msgs = await svc.messages({ sessionID: session.id })
const abort = new AbortController()
const rt = liveRuntime(stub.layer, wide())
let off: (() => void) | undefined
@@ -1063,9 +1084,9 @@ describe("session.compaction.process", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const msg = await user(session.id, "hello")
const msgs = await Session.messages({ sessionID: session.id })
const msgs = await svc.messages({ sessionID: session.id })
const abort = new AbortController()
const rt = runtime("continue", plugin(ready), wide())
let run: Promise<"continue" | "stop"> | undefined
@@ -1100,7 +1121,7 @@ describe("session.compaction.process", () => {
abort.abort()
expect(await run).toBe("stop")
const all = await Session.messages({ sessionID: session.id })
const all = await svc.messages({ sessionID: session.id })
expect(all.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(false)
} finally {
abort.abort()
@@ -1165,11 +1186,11 @@ describe("session.compaction.process", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const msg = await user(session.id, "hello")
const rt = liveRuntime(stub.layer, wide())
try {
const msgs = await Session.messages({ sessionID: session.id })
const msgs = await svc.messages({ sessionID: session.id })
await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
@@ -1181,7 +1202,7 @@ describe("session.compaction.process", () => {
),
)
const summary = (await Session.messages({ sessionID: session.id })).find(
const summary = (await svc.messages({ sessionID: session.id })).find(
(item) => item.info.role === "assistant" && item.info.summary,
)
@@ -1211,10 +1232,10 @@ describe("util.token.estimate", () => {
})
})
describe("session.getUsage", () => {
describe("SessionNs.getUsage", () => {
test("normalizes standard usage to token format", () => {
const model = createModel({ context: 100_000, output: 32_000 })
const result = Session.getUsage({
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1000,
@@ -1241,7 +1262,7 @@ describe("session.getUsage", () => {
test("extracts cached tokens to cache.read", () => {
const model = createModel({ context: 100_000, output: 32_000 })
const result = Session.getUsage({
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1000,
@@ -1265,7 +1286,7 @@ describe("session.getUsage", () => {
test("handles anthropic cache write metadata", () => {
const model = createModel({ context: 100_000, output: 32_000 })
const result = Session.getUsage({
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1000,
@@ -1294,7 +1315,7 @@ describe("session.getUsage", () => {
test("subtracts cached tokens for anthropic provider", () => {
const model = createModel({ context: 100_000, output: 32_000 })
// AI SDK v6 normalizes inputTokens to include cached tokens for all providers
const result = Session.getUsage({
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1000,
@@ -1321,7 +1342,7 @@ describe("session.getUsage", () => {
test("separates reasoning tokens from output tokens", () => {
const model = createModel({ context: 100_000, output: 32_000 })
const result = Session.getUsage({
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1000,
@@ -1355,7 +1376,7 @@ describe("session.getUsage", () => {
cache: { read: 0, write: 0 },
},
})
const result = Session.getUsage({
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 0,
@@ -1380,7 +1401,7 @@ describe("session.getUsage", () => {
test("handles undefined optional values gracefully", () => {
const model = createModel({ context: 100_000, output: 32_000 })
const result = Session.getUsage({
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 0,
@@ -1416,7 +1437,7 @@ describe("session.getUsage", () => {
cache: { read: 0.3, write: 3.75 },
},
})
const result = Session.getUsage({
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1_000_000,
@@ -1457,7 +1478,7 @@ describe("session.getUsage", () => {
},
}
if (npm === "@ai-sdk/amazon-bedrock") {
const result = Session.getUsage({
const result = SessionNs.getUsage({
model,
usage,
metadata: {
@@ -1478,7 +1499,7 @@ describe("session.getUsage", () => {
return
}
const result = Session.getUsage({
const result = SessionNs.getUsage({
model,
usage,
metadata: {
@@ -1499,7 +1520,7 @@ describe("session.getUsage", () => {
test("extracts cache write tokens from vertex metadata key", () => {
const model = createModel({ context: 100_000, output: 32_000, npm: "@ai-sdk/google-vertex/anthropic" })
const result = Session.getUsage({
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1000,
@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import path from "path"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
import { Session as SessionNs } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
@@ -10,12 +11,32 @@ import { Log } from "../../src/util/log"
const root = path.join(__dirname, "../..")
Log.init({ print: false })
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
}
const svc = {
...SessionNs,
create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input)))
},
remove(id: SessionID) {
return run(SessionNs.Service.use((svc) => svc.remove(id)))
},
updateMessage<T extends MessageV2.Info>(msg: T) {
return run(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
},
updatePart<T extends MessageV2.Part>(part: T) {
return run(SessionNs.Service.use((svc) => svc.updatePart(part)))
},
}
async function fill(sessionID: SessionID, count: number, time = (i: number) => Date.now() + i) {
const ids = [] as MessageID[]
for (let i = 0; i < count; i++) {
const id = MessageID.ascending()
ids.push(id)
await Session.updateMessage({
await svc.updateMessage({
id,
sessionID,
role: "user",
@@ -25,7 +46,7 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D
tools: {},
mode: "",
} as unknown as MessageV2.Info)
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID,
messageID: id,
@@ -38,7 +59,7 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D
async function addUser(sessionID: SessionID, text?: string) {
const id = MessageID.ascending()
await Session.updateMessage({
await svc.updateMessage({
id,
sessionID,
role: "user",
@@ -49,7 +70,7 @@ async function addUser(sessionID: SessionID, text?: string) {
mode: "",
} as unknown as MessageV2.Info)
if (text) {
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID,
messageID: id,
@@ -66,7 +87,7 @@ async function addAssistant(
opts?: { summary?: boolean; finish?: string; error?: MessageV2.Assistant["error"] },
) {
const id = MessageID.ascending()
await Session.updateMessage({
await svc.updateMessage({
id,
sessionID,
role: "assistant",
@@ -87,7 +108,7 @@ async function addAssistant(
}
async function addCompactionPart(sessionID: SessionID, messageID: MessageID) {
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID,
messageID,
@@ -101,14 +122,14 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
await fill(session.id, 2)
const result = MessageV2.page({ sessionID: session.id, limit: 10 })
expect(result).toBeDefined()
expect(result.items).toBeArray()
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -117,7 +138,7 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 6)
const a = MessageV2.page({ sessionID: session.id, limit: 2 })
@@ -136,7 +157,7 @@ describe("MessageV2.page", () => {
expect(c.more).toBe(false)
expect(c.cursor).toBeUndefined()
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -145,13 +166,13 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 4)
const result = MessageV2.page({ sessionID: session.id, limit: 4 })
expect(result.items.map((item) => item.info.id)).toEqual(ids)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -160,14 +181,14 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const result = MessageV2.page({ sessionID: session.id, limit: 10 })
expect(result.items).toEqual([])
expect(result.more).toBe(false)
expect(result.cursor).toBeUndefined()
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -186,7 +207,7 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 3)
const result = MessageV2.page({ sessionID: session.id, limit: 3 })
@@ -194,7 +215,7 @@ describe("MessageV2.page", () => {
expect(result.more).toBe(false)
expect(result.cursor).toBeUndefined()
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -203,7 +224,7 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 5)
const result = MessageV2.page({ sessionID: session.id, limit: 1 })
@@ -211,7 +232,7 @@ describe("MessageV2.page", () => {
expect(result.items[0].info.id).toBe(ids[ids.length - 1])
expect(result.more).toBe(true)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -220,10 +241,10 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const [id] = await fill(session.id, 1)
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: id,
@@ -235,7 +256,7 @@ describe("MessageV2.page", () => {
expect(result.items).toHaveLength(1)
expect(result.items[0].parts).toHaveLength(2)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -244,7 +265,7 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 4, (i) => 1000.5 + i)
const a = MessageV2.page({ sessionID: session.id, limit: 2 })
@@ -253,7 +274,7 @@ describe("MessageV2.page", () => {
expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2))
expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2))
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -262,7 +283,7 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 4, () => 1000)
const a = MessageV2.page({ sessionID: session.id, limit: 2 })
@@ -273,7 +294,7 @@ describe("MessageV2.page", () => {
expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2))
expect(b.more).toBe(false)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -282,8 +303,8 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const a = await Session.create({})
const b = await Session.create({})
const a = await svc.create({})
const b = await svc.create({})
await fill(a.id, 3)
await fill(b.id, 2)
@@ -294,8 +315,8 @@ describe("MessageV2.page", () => {
expect(resultA.items.every((item) => item.info.sessionID === a.id)).toBe(true)
expect(resultB.items.every((item) => item.info.sessionID === b.id)).toBe(true)
await Session.remove(a.id)
await Session.remove(b.id)
await svc.remove(a.id)
await svc.remove(b.id)
},
})
})
@@ -304,7 +325,7 @@ describe("MessageV2.page", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 10)
const result = MessageV2.page({ sessionID: session.id, limit: 100 })
@@ -313,7 +334,7 @@ describe("MessageV2.page", () => {
expect(result.more).toBe(false)
expect(result.cursor).toBeUndefined()
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -324,13 +345,13 @@ describe("MessageV2.stream", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 5)
const items = Array.from(MessageV2.stream(session.id))
expect(items.map((item) => item.info.id)).toEqual(ids.slice().reverse())
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -339,12 +360,12 @@ describe("MessageV2.stream", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const items = Array.from(MessageV2.stream(session.id))
expect(items).toHaveLength(0)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -353,14 +374,14 @@ describe("MessageV2.stream", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 1)
const items = Array.from(MessageV2.stream(session.id))
expect(items).toHaveLength(1)
expect(items[0].info.id).toBe(ids[0])
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -369,7 +390,7 @@ describe("MessageV2.stream", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
await fill(session.id, 3)
const items = Array.from(MessageV2.stream(session.id))
@@ -378,7 +399,7 @@ describe("MessageV2.stream", () => {
expect(item.parts[0].type).toBe("text")
}
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -387,7 +408,7 @@ describe("MessageV2.stream", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 60)
const items = Array.from(MessageV2.stream(session.id))
@@ -395,7 +416,7 @@ describe("MessageV2.stream", () => {
expect(items[0].info.id).toBe(ids[ids.length - 1])
expect(items[59].info.id).toBe(ids[0])
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -404,7 +425,7 @@ describe("MessageV2.stream", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
await fill(session.id, 1)
const gen = MessageV2.stream(session.id)
@@ -414,7 +435,7 @@ describe("MessageV2.stream", () => {
expect(first).toHaveProperty("done")
expect(first.done).toBe(false)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -425,7 +446,7 @@ describe("MessageV2.parts", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const [id] = await fill(session.id, 1)
const result = MessageV2.parts(id)
@@ -433,7 +454,7 @@ describe("MessageV2.parts", () => {
expect(result[0].type).toBe("text")
expect((result[0] as MessageV2.TextPart).text).toBe("m0")
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -442,13 +463,13 @@ describe("MessageV2.parts", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const id = await addUser(session.id)
const result = MessageV2.parts(id)
expect(result).toEqual([])
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -457,17 +478,17 @@ describe("MessageV2.parts", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const [id] = await fill(session.id, 1)
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: id,
type: "text",
text: "second",
})
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: id,
@@ -481,7 +502,7 @@ describe("MessageV2.parts", () => {
expect((result[1] as MessageV2.TextPart).text).toBe("second")
expect((result[2] as MessageV2.TextPart).text).toBe("third")
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -490,7 +511,7 @@ describe("MessageV2.parts", () => {
await Instance.provide({
directory: root,
fn: async () => {
await Session.create({})
await svc.create({})
const result = MessageV2.parts(MessageID.ascending())
expect(result).toEqual([])
},
@@ -501,14 +522,14 @@ describe("MessageV2.parts", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const [id] = await fill(session.id, 1)
const result = MessageV2.parts(id)
expect(result[0].sessionID).toBe(session.id)
expect(result[0].messageID).toBe(id)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -519,7 +540,7 @@ describe("MessageV2.get", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const [id] = await fill(session.id, 1)
const result = MessageV2.get({ sessionID: session.id, messageID: id })
@@ -529,7 +550,7 @@ describe("MessageV2.get", () => {
expect(result.parts).toHaveLength(1)
expect((result.parts[0] as MessageV2.TextPart).text).toBe("m0")
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -538,13 +559,13 @@ describe("MessageV2.get", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
expect(() => MessageV2.get({ sessionID: session.id, messageID: MessageID.ascending() })).toThrow(
"NotFoundError",
)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -553,16 +574,16 @@ describe("MessageV2.get", () => {
await Instance.provide({
directory: root,
fn: async () => {
const a = await Session.create({})
const b = await Session.create({})
const a = await svc.create({})
const b = await svc.create({})
const [id] = await fill(a.id, 1)
expect(() => MessageV2.get({ sessionID: b.id, messageID: id })).toThrow("NotFoundError")
const result = MessageV2.get({ sessionID: a.id, messageID: id })
expect(result.info.id).toBe(id)
await Session.remove(a.id)
await Session.remove(b.id)
await svc.remove(a.id)
await svc.remove(b.id)
},
})
})
@@ -571,10 +592,10 @@ describe("MessageV2.get", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const [id] = await fill(session.id, 1)
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: id,
@@ -585,7 +606,7 @@ describe("MessageV2.get", () => {
const result = MessageV2.get({ sessionID: session.id, messageID: id })
expect(result.parts).toHaveLength(2)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -594,11 +615,11 @@ describe("MessageV2.get", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const uid = await addUser(session.id, "hello")
const aid = await addAssistant(session.id, uid)
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: aid,
@@ -611,7 +632,7 @@ describe("MessageV2.get", () => {
expect(result.parts).toHaveLength(1)
expect((result.parts[0] as MessageV2.TextPart).text).toBe("response")
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -620,14 +641,14 @@ describe("MessageV2.get", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const id = await addUser(session.id)
const result = MessageV2.get({ sessionID: session.id, messageID: id })
expect(result.info.id).toBe(id)
expect(result.parts).toEqual([])
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -638,7 +659,7 @@ describe("MessageV2.filterCompacted", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 5)
const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
@@ -646,7 +667,7 @@ describe("MessageV2.filterCompacted", () => {
// reversed from newest-first to chronological
expect(result.map((item) => item.info.id)).toEqual(ids)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -655,13 +676,13 @@ describe("MessageV2.filterCompacted", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
// Chronological: u1(+compaction part), a1(summary, parentID=u1), u2, a2
// Stream (newest first): a2, u2, a1(adds u1 to completed), u1(in completed + compaction) -> break
const u1 = await addUser(session.id, "first question")
const a1 = await addAssistant(session.id, u1, { summary: true, finish: "end_turn" })
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: a1,
@@ -672,7 +693,7 @@ describe("MessageV2.filterCompacted", () => {
const u2 = await addUser(session.id, "new question")
const a2 = await addAssistant(session.id, u2)
await Session.updatePart({
await svc.updatePart({
id: PartID.ascending(),
sessionID: session.id,
messageID: a2,
@@ -685,7 +706,7 @@ describe("MessageV2.filterCompacted", () => {
expect(result[0].info.id).toBe(u1)
expect(result.length).toBe(4)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -699,7 +720,7 @@ describe("MessageV2.filterCompacted", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const u1 = await addUser(session.id, "hello")
await addCompactionPart(session.id, u1)
@@ -708,7 +729,7 @@ describe("MessageV2.filterCompacted", () => {
const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
expect(result).toHaveLength(2)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -717,7 +738,7 @@ describe("MessageV2.filterCompacted", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const u1 = await addUser(session.id, "hello")
await addCompactionPart(session.id, u1)
@@ -733,7 +754,7 @@ describe("MessageV2.filterCompacted", () => {
// Error assistant doesn't add to completed, so compaction boundary never triggers
expect(result).toHaveLength(3)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -742,7 +763,7 @@ describe("MessageV2.filterCompacted", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const u1 = await addUser(session.id, "hello")
await addCompactionPart(session.id, u1)
@@ -754,7 +775,7 @@ describe("MessageV2.filterCompacted", () => {
const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
expect(result).toHaveLength(3)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -808,7 +829,7 @@ describe("MessageV2 consistency", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
await fill(session.id, 3)
const paged = MessageV2.page({ sessionID: session.id, limit: 10 })
@@ -818,7 +839,7 @@ describe("MessageV2 consistency", () => {
expect(got.parts).toEqual(item.parts)
}
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -827,14 +848,14 @@ describe("MessageV2 consistency", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const [id] = await fill(session.id, 1)
const got = MessageV2.get({ sessionID: session.id, messageID: id })
const standalone = MessageV2.parts(id)
expect(got.parts).toEqual(standalone)
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -843,7 +864,7 @@ describe("MessageV2 consistency", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
await fill(session.id, 7)
const streamed = Array.from(MessageV2.stream(session.id))
@@ -861,7 +882,7 @@ describe("MessageV2 consistency", () => {
expect(streamed.map((m) => m.info.id)).toEqual(paged.map((m) => m.info.id))
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -870,7 +891,7 @@ describe("MessageV2 consistency", () => {
await Instance.provide({
directory: root,
fn: async () => {
const session = await Session.create({})
const session = await svc.create({})
const ids = await fill(session.id, 4)
const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id))
@@ -878,7 +899,7 @@ describe("MessageV2 consistency", () => {
expect(filtered.map((m) => m.info.id)).toEqual(all.map((m) => m.info.id))
await Session.remove(session.id)
await svc.remove(session.id)
},
})
})
@@ -40,7 +40,7 @@ import { ToolRegistry } from "../../src/tool/registry"
import { Truncate } from "../../src/tool/truncate"
import { Log } from "../../src/util/log"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Search } from "../../src/file/search"
import { Ripgrep } from "../../src/file/ripgrep"
import { Format } from "../../src/format"
import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -187,7 +187,7 @@ function makeHttp() {
Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Search.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provideMerge(todo),
Layer.provideMerge(question),
@@ -210,7 +210,7 @@ function makeHttp() {
Layer.provide(SystemPrompt.defaultLayer),
Layer.provideMerge(deps),
),
)
).pipe(Layer.provide(summary))
}
const it = testEffect(makeHttp())
@@ -384,25 +384,23 @@ it.live("loop calls LLM and returns assistant message", () =>
it.live("static loop returns assistant text through local provider", () =>
provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }) {
const session = yield* Effect.promise(() =>
Session.create({
title: "Prompt provider",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
}),
)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({
title: "Prompt provider",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
yield* Effect.promise(() =>
SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}),
)
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
yield* llm.text("world")
const result = yield* Effect.promise(() => SessionPrompt.loop({ sessionID: session.id }))
const result = yield* prompt.loop({ sessionID: session.id })
expect(result.info.role).toBe("assistant")
expect(result.parts.some((part) => part.type === "text" && part.text === "world")).toBe(true)
expect(yield* llm.hits).toHaveLength(1)
@@ -415,40 +413,36 @@ it.live("static loop returns assistant text through local provider", () =>
it.live("static loop consumes queued replies across turns", () =>
provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }) {
const session = yield* Effect.promise(() =>
Session.create({
title: "Prompt provider turns",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
}),
)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({
title: "Prompt provider turns",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
yield* Effect.promise(() =>
SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello one" }],
}),
)
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello one" }],
})
yield* llm.text("world one")
const first = yield* Effect.promise(() => SessionPrompt.loop({ sessionID: session.id }))
const first = yield* prompt.loop({ sessionID: session.id })
expect(first.info.role).toBe("assistant")
expect(first.parts.some((part) => part.type === "text" && part.text === "world one")).toBe(true)
yield* Effect.promise(() =>
SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello two" }],
}),
)
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello two" }],
})
yield* llm.text("world two")
const second = yield* Effect.promise(() => SessionPrompt.loop({ sessionID: session.id }))
const second = yield* prompt.loop({ sessionID: session.id })
expect(second.info.role).toBe("assistant")
expect(second.parts.some((part) => part.type === "text" && part.text === "world two")).toBe(true)
+267 -199
View File
@@ -2,6 +2,7 @@ import path from "path"
import { describe, expect, test } from "bun:test"
import { NamedError } from "@opencode-ai/util/error"
import { fileURLToPath } from "url"
import { Effect, Layer } from "effect"
import { Instance } from "../../src/project/instance"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Session } from "../../src/session"
@@ -12,6 +13,12 @@ import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
function run<A, E>(fx: Effect.Effect<A, E, SessionPrompt.Service | Session.Service>) {
return Effect.runPromise(
fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))),
)
}
function defer<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
const promise = new Promise<T>((done) => {
@@ -104,34 +111,39 @@ describe("session.prompt missing file", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const missing = path.join(tmp.path, "does-not-exist.ts")
const msg = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{ type: "text", text: "please review @does-not-exist.ts" },
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "does-not-exist.ts",
},
],
})
const missing = path.join(tmp.path, "does-not-exist.ts")
const msg = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{ type: "text", text: "please review @does-not-exist.ts" },
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "does-not-exist.ts",
},
],
})
if (msg.info.role !== "user") throw new Error("expected user message")
if (msg.info.role !== "user") throw new Error("expected user message")
const hasFailure = msg.parts.some(
(part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"),
)
expect(hasFailure).toBe(true)
const hasFailure = msg.parts.some(
(part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"),
)
expect(hasFailure).toBe(true)
await Session.remove(session.id)
},
yield* sessions.remove(session.id)
}),
),
})
})
@@ -149,39 +161,44 @@ describe("session.prompt missing file", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const missing = path.join(tmp.path, "still-missing.ts")
const msg = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "still-missing.ts",
},
{ type: "text", text: "after-file" },
],
})
const missing = path.join(tmp.path, "still-missing.ts")
const msg = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "still-missing.ts",
},
{ type: "text", text: "after-file" },
],
})
if (msg.info.role !== "user") throw new Error("expected user message")
if (msg.info.role !== "user") throw new Error("expected user message")
const stored = await MessageV2.get({
sessionID: session.id,
messageID: msg.info.id,
})
const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text)
const stored = MessageV2.get({
sessionID: session.id,
messageID: msg.info.id,
})
const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text)
expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true)
expect(text[1]?.includes("Read tool failed to read")).toBe(true)
expect(text[2]).toBe("after-file")
expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true)
expect(text[1]?.includes("Read tool failed to read")).toBe(true)
expect(text[2]).toBe("after-file")
await Session.remove(session.id)
},
yield* sessions.remove(session.id)
}),
),
})
})
})
@@ -197,31 +214,36 @@ describe("session.prompt special characters", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const template = "Read @file#name.txt"
const parts = await SessionPrompt.resolvePromptParts(template)
const fileParts = parts.filter((part) => part.type === "file")
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const template = "Read @file#name.txt"
const parts = yield* prompt.resolvePromptParts(template)
const fileParts = parts.filter((part) => part.type === "file")
expect(fileParts.length).toBe(1)
expect(fileParts[0].filename).toBe("file#name.txt")
expect(fileParts[0].url).toContain("%23")
expect(fileParts.length).toBe(1)
expect(fileParts[0].filename).toBe("file#name.txt")
expect(fileParts[0].url).toContain("%23")
const decodedPath = fileURLToPath(fileParts[0].url)
expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt"))
const decodedPath = fileURLToPath(fileParts[0].url)
expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt"))
const message = await SessionPrompt.prompt({
sessionID: session.id,
parts,
noReply: true,
})
const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id })
const textParts = stored.parts.filter((part) => part.type === "text")
const hasContent = textParts.some((part) => part.text.includes("special content"))
expect(hasContent).toBe(true)
const message = yield* prompt.prompt({
sessionID: session.id,
parts,
noReply: true,
})
const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id })
const textParts = stored.parts.filter((part) => part.type === "text")
const hasContent = textParts.some((part) => part.text.includes("special content"))
expect(hasContent).toBe(true)
await Session.remove(session.id)
},
yield* sessions.remove(session.id)
}),
),
})
})
})
@@ -273,21 +295,26 @@ describe("session.prompt regression", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({ title: "Prompt regression" })
const result = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
parts: [{ type: "text", text: "Where is SessionProcessor?" }],
})
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Prompt regression" })
const result = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
parts: [{ type: "text", text: "Where is SessionProcessor?" }],
})
expect(result.info.role).toBe("assistant")
expect(result.parts.some((part) => part.type === "text" && part.text.includes("processor.ts"))).toBe(true)
expect(result.info.role).toBe("assistant")
expect(result.parts.some((part) => part.type === "text" && part.text.includes("processor.ts"))).toBe(true)
const msgs = await Session.messages({ sessionID: session.id })
expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1)
expect(calls).toBe(1)
},
const msgs = yield* sessions.messages({ sessionID: session.id })
expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1)
expect(calls).toBe(1)
}),
),
})
} finally {
server.stop(true)
@@ -342,36 +369,45 @@ describe("session.prompt regression", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({ title: "Prompt cancel regression" })
const run = SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
parts: [{ type: "text", text: "Cancel me" }],
})
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Prompt cancel regression" })
const task = Effect.runPromise(
prompt.prompt({
sessionID: session.id,
agent: "build",
parts: [{ type: "text", text: "Cancel me" }],
}),
)
await ready.promise
await SessionPrompt.cancel(session.id)
yield* Effect.promise(() => ready.promise)
yield* prompt.cancel(session.id)
const result = await Promise.race([
run,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("timed out waiting for cancel")), 1000),
),
])
const result = yield* Effect.promise(() =>
Promise.race([
task,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("timed out waiting for cancel")), 1000),
),
]),
)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.error?.name).toBe("MessageAbortedError")
}
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.error?.name).toBe("MessageAbortedError")
}
const msgs = await Session.messages({ sessionID: session.id })
const last = msgs.findLast((msg) => msg.info.role === "assistant")
expect(last?.info.role).toBe("assistant")
if (last?.info.role === "assistant") {
expect(last.info.error?.name).toBe("MessageAbortedError")
}
},
const msgs = yield* sessions.messages({ sessionID: session.id })
const last = msgs.findLast((msg) => msg.info.role === "assistant")
expect(last?.info.role).toBe("assistant")
if (last?.info.role === "assistant") {
expect(last.info.error?.name).toBe("MessageAbortedError")
}
}),
),
})
} finally {
server.stop(true)
@@ -399,45 +435,50 @@ describe("session.prompt agent variant", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const other = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
model: { providerID: ProviderID.make("opencode"), modelID: ModelID.make("kimi-k2.5-free") },
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
if (other.info.role !== "user") throw new Error("expected user message")
expect(other.info.model.variant).toBeUndefined()
const other = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
model: { providerID: ProviderID.make("opencode"), modelID: ModelID.make("kimi-k2.5-free") },
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
if (other.info.role !== "user") throw new Error("expected user message")
expect(other.info.model.variant).toBeUndefined()
const match = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello again" }],
})
if (match.info.role !== "user") throw new Error("expected user message")
expect(match.info.model).toEqual({
providerID: ProviderID.make("openai"),
modelID: ModelID.make("gpt-5.2"),
variant: "xhigh",
})
expect(match.info.model.variant).toBe("xhigh")
const match = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello again" }],
})
if (match.info.role !== "user") throw new Error("expected user message")
expect(match.info.model).toEqual({
providerID: ProviderID.make("openai"),
modelID: ModelID.make("gpt-5.2"),
variant: "xhigh",
})
expect(match.info.model.variant).toBe("xhigh")
const override = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
variant: "high",
parts: [{ type: "text", text: "hello third" }],
})
if (override.info.role !== "user") throw new Error("expected user message")
expect(override.info.model.variant).toBe("high")
const override = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
variant: "high",
parts: [{ type: "text", text: "hello third" }],
})
if (override.info.role !== "user") throw new Error("expected user message")
expect(override.info.model.variant).toBe("high")
await Session.remove(session.id)
},
yield* sessions.remove(session.id)
}),
),
})
} finally {
if (prev === undefined) delete process.env.OPENAI_API_KEY
@@ -451,24 +492,33 @@ describe("session.agent-resolution", () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const err = await SessionPrompt.prompt({
sessionID: session.id,
agent: "nonexistent-agent-xyz",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}).then(
() => undefined,
(e) => e,
)
expect(err).toBeDefined()
expect(err).not.toBeInstanceOf(TypeError)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"')
}
},
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const err = yield* Effect.promise(() =>
Effect.runPromise(
prompt.prompt({
sessionID: session.id,
agent: "nonexistent-agent-xyz",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}),
).then(
() => undefined,
(e) => e,
),
)
expect(err).toBeDefined()
expect(err).not.toBeInstanceOf(TypeError)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"')
}
}),
),
})
}, 30000)
@@ -476,22 +526,31 @@ describe("session.agent-resolution", () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const err = await SessionPrompt.prompt({
sessionID: session.id,
agent: "nonexistent-agent-xyz",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}).then(
() => undefined,
(e) => e,
)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain("build")
}
},
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const err = yield* Effect.promise(() =>
Effect.runPromise(
prompt.prompt({
sessionID: session.id,
agent: "nonexistent-agent-xyz",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}),
).then(
() => undefined,
(e) => e,
),
)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain("build")
}
}),
),
})
}, 30000)
@@ -499,24 +558,33 @@ describe("session.agent-resolution", () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const err = await SessionPrompt.command({
sessionID: session.id,
command: "nonexistent-command-xyz",
arguments: "",
}).then(
() => undefined,
(e) => e,
)
expect(err).toBeDefined()
expect(err).not.toBeInstanceOf(TypeError)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"')
expect(err.data.message).toContain("init")
}
},
fn: () =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const err = yield* Effect.promise(() =>
Effect.runPromise(
prompt.command({
sessionID: session.id,
command: "nonexistent-command-xyz",
arguments: "",
}),
).then(
() => undefined,
(e) => e,
),
)
expect(err).toBeDefined()
expect(err).not.toBeInstanceOf(TypeError)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"')
expect(err.data.message).toContain("init")
}
}),
),
})
}, 30000)
})
+46 -30
View File
@@ -1,43 +1,62 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Session } from "../../src/session"
import { Session as SessionNs } from "../../src/session"
import { Bus } from "../../src/bus"
import { Log } from "../../src/util/log"
import { Instance } from "../../src/project/instance"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID } from "../../src/session/schema"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { AppRuntime } from "../../src/effect/app-runtime"
import { tmpdir } from "../fixture/fixture"
const projectRoot = path.join(__dirname, "../..")
Log.init({ print: false })
function create(input?: SessionNs.CreateInput) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create(input)))
}
function get(id: SessionID) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(id)))
}
function remove(id: SessionID) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.remove(id)))
}
function updateMessage<T extends MessageV2.Info>(msg: T) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
}
function updatePart<T extends MessageV2.Part>(part: T) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updatePart(part)))
}
describe("session.created event", () => {
test("should emit session.created event when session is created", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
let eventReceived = false
let receivedInfo: Session.Info | undefined
let receivedInfo: SessionNs.Info | undefined
const unsub = Bus.subscribe(Session.Event.Created, (event) => {
const unsub = Bus.subscribe(SessionNs.Event.Created, (event) => {
eventReceived = true
receivedInfo = event.properties.info as Session.Info
receivedInfo = event.properties.info as SessionNs.Info
})
const session = await Session.create({})
const info = await create({})
await new Promise((resolve) => setTimeout(resolve, 100))
unsub()
expect(eventReceived).toBe(true)
expect(receivedInfo).toBeDefined()
expect(receivedInfo?.id).toBe(session.id)
expect(receivedInfo?.projectID).toBe(session.projectID)
expect(receivedInfo?.directory).toBe(session.directory)
expect(receivedInfo?.title).toBe(session.title)
expect(receivedInfo?.id).toBe(info.id)
expect(receivedInfo?.projectID).toBe(info.projectID)
expect(receivedInfo?.directory).toBe(info.directory)
expect(receivedInfo?.title).toBe(info.title)
await Session.remove(session.id)
await remove(info.id)
},
})
})
@@ -48,18 +67,16 @@ describe("session.created event", () => {
fn: async () => {
const events: string[] = []
const unsubCreated = Bus.subscribe(Session.Event.Created, () => {
const unsubCreated = Bus.subscribe(SessionNs.Event.Created, () => {
events.push("created")
})
const unsubUpdated = Bus.subscribe(Session.Event.Updated, () => {
const unsubUpdated = Bus.subscribe(SessionNs.Event.Updated, () => {
events.push("updated")
})
const session = await Session.create({})
const info = await create({})
await new Promise((resolve) => setTimeout(resolve, 100))
unsubCreated()
unsubUpdated()
@@ -67,7 +84,7 @@ describe("session.created event", () => {
expect(events).toContain("updated")
expect(events.indexOf("created")).toBeLessThan(events.indexOf("updated"))
await Session.remove(session.id)
await remove(info.id)
},
})
})
@@ -80,12 +97,12 @@ describe("step-finish token propagation via Bus event", () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const session = await Session.create({})
const info = await create({})
const messageID = MessageID.ascending()
await Session.updateMessage({
await updateMessage({
id: messageID,
sessionID: session.id,
sessionID: info.id,
role: "user",
time: { created: Date.now() },
agent: "user",
@@ -110,15 +127,14 @@ describe("step-finish token propagation via Bus event", () => {
const partInput = {
id: PartID.ascending(),
messageID,
sessionID: session.id,
sessionID: info.id,
type: "step-finish" as const,
reason: "stop",
cost: 0.005,
tokens,
}
await Session.updatePart(partInput)
await updatePart(partInput)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(received).toBeDefined()
@@ -134,7 +150,7 @@ describe("step-finish token propagation via Bus event", () => {
expect(received).not.toBe(partInput)
unsub()
await Session.remove(session.id)
await remove(info.id)
},
})
},
@@ -146,17 +162,17 @@ describe("Session", () => {
test("remove works without an instance", async () => {
await using tmp = await tmpdir({ git: true })
const session = await Instance.provide({
const info = await Instance.provide({
directory: tmp.path,
fn: async () => Session.create({ title: "remove-without-instance" }),
fn: () => create({ title: "remove-without-instance" }),
})
await expect(async () => {
await Session.remove(session.id)
await remove(info.id)
}).not.toThrow()
let missing = false
await Session.get(session.id).catch(() => {
await get(info.id).catch(() => {
missing = true
})
@@ -55,7 +55,7 @@ import { ToolRegistry } from "../../src/tool/registry"
import { Truncate } from "../../src/tool/truncate"
import { AppFileSystem } from "../../src/filesystem"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Search } from "../../src/file/search"
import { Ripgrep } from "../../src/file/ripgrep"
import { Format } from "../../src/format"
Log.init({ print: false })
@@ -141,7 +141,7 @@ function makeHttp() {
Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Search.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provideMerge(todo),
Layer.provideMerge(question),
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Effect, Layer } from "effect"
import { Session } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt"
import { Log } from "../../src/util/log"
@@ -20,51 +21,63 @@ async function withInstance<T>(fn: () => Promise<T>): Promise<T> {
})
}
function run<A, E>(fx: Effect.Effect<A, E, SessionPrompt.Service | Session.Service>) {
return Effect.runPromise(
fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))),
)
}
describe("StructuredOutput Integration", () => {
test.skipIf(!hasApiKey)(
"produces structured output with simple schema",
async () => {
await withInstance(async () => {
const session = await Session.create({ title: "Structured Output Test" })
await withInstance(() =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Structured Output Test" })
const result = await SessionPrompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "What is 2 + 2? Provide a simple answer.",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
answer: { type: "number", description: "The numerical answer" },
explanation: { type: "string", description: "Brief explanation" },
const result = yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "What is 2 + 2? Provide a simple answer.",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
answer: { type: "number", description: "The numerical answer" },
explanation: { type: "string", description: "Brief explanation" },
},
required: ["answer"],
},
retryCount: 0,
},
required: ["answer"],
},
retryCount: 0,
},
})
})
// Verify structured output was captured (only on assistant messages)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeDefined()
expect(typeof result.info.structured).toBe("object")
// Verify structured output was captured (only on assistant messages)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeDefined()
expect(typeof result.info.structured).toBe("object")
const output = result.info.structured as any
expect(output.answer).toBe(4)
const output = result.info.structured as any
expect(output.answer).toBe(4)
// Verify no error was set
expect(result.info.error).toBeUndefined()
}
// Verify no error was set
expect(result.info.error).toBeUndefined()
}
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
})
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
),
)
},
60000,
)
@@ -72,62 +85,68 @@ describe("StructuredOutput Integration", () => {
test.skipIf(!hasApiKey)(
"produces structured output with nested objects",
async () => {
await withInstance(async () => {
const session = await Session.create({ title: "Nested Schema Test" })
await withInstance(() =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Nested Schema Test" })
const result = await SessionPrompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "Tell me about Anthropic company in a structured format.",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
company: {
const result = yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "Tell me about Anthropic company in a structured format.",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
name: { type: "string" },
founded: { type: "number" },
company: {
type: "object",
properties: {
name: { type: "string" },
founded: { type: "number" },
},
required: ["name", "founded"],
},
products: {
type: "array",
items: { type: "string" },
},
},
required: ["name", "founded"],
},
products: {
type: "array",
items: { type: "string" },
required: ["company"],
},
retryCount: 0,
},
required: ["company"],
},
retryCount: 0,
},
})
})
// Verify structured output was captured (only on assistant messages)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeDefined()
const output = result.info.structured as any
// Verify structured output was captured (only on assistant messages)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeDefined()
const output = result.info.structured as any
expect(output.company).toBeDefined()
expect(output.company.name).toBe("Anthropic")
expect(typeof output.company.founded).toBe("number")
expect(output.company).toBeDefined()
expect(output.company.name).toBe("Anthropic")
expect(typeof output.company.founded).toBe("number")
if (output.products) {
expect(Array.isArray(output.products)).toBe(true)
}
if (output.products) {
expect(Array.isArray(output.products)).toBe(true)
}
// Verify no error was set
expect(result.info.error).toBeUndefined()
}
// Verify no error was set
expect(result.info.error).toBeUndefined()
}
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
})
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
),
)
},
60000,
)
@@ -135,35 +154,41 @@ describe("StructuredOutput Integration", () => {
test.skipIf(!hasApiKey)(
"works with text outputFormat (default)",
async () => {
await withInstance(async () => {
const session = await Session.create({ title: "Text Output Test" })
await withInstance(() =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Text Output Test" })
const result = await SessionPrompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "Say hello.",
},
],
format: {
type: "text",
},
})
const result = yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "Say hello.",
},
],
format: {
type: "text",
},
})
// Verify no structured output (text mode) and no error
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeUndefined()
expect(result.info.error).toBeUndefined()
}
// Verify no structured output (text mode) and no error
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.structured).toBeUndefined()
expect(result.info.error).toBeUndefined()
}
// Verify we got a response with parts
expect(result.parts.length).toBeGreaterThan(0)
// Verify we got a response with parts
expect(result.parts.length).toBeGreaterThan(0)
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
})
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
),
)
},
60000,
)
@@ -171,47 +196,53 @@ describe("StructuredOutput Integration", () => {
test.skipIf(!hasApiKey)(
"stores outputFormat on user message",
async () => {
await withInstance(async () => {
const session = await Session.create({ title: "OutputFormat Storage Test" })
await withInstance(() =>
run(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "OutputFormat Storage Test" })
await SessionPrompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "What is 1 + 1?",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
result: { type: "number" },
yield* prompt.prompt({
sessionID: session.id,
parts: [
{
type: "text",
text: "What is 1 + 1?",
},
],
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
result: { type: "number" },
},
required: ["result"],
},
retryCount: 3,
},
required: ["result"],
},
retryCount: 3,
},
})
})
// Get all messages from session
const messages = await Session.messages({ sessionID: session.id })
const userMessage = messages.find((m) => m.info.role === "user")
// Get all messages from session
const messages = yield* sessions.messages({ sessionID: session.id })
const userMessage = messages.find((m) => m.info.role === "user")
// Verify outputFormat was stored on user message
expect(userMessage).toBeDefined()
if (userMessage?.info.role === "user") {
expect(userMessage.info.format).toBeDefined()
expect(userMessage.info.format?.type).toBe("json_schema")
if (userMessage.info.format?.type === "json_schema") {
expect(userMessage.info.format.retryCount).toBe(3)
}
}
// Verify outputFormat was stored on user message
expect(userMessage).toBeDefined()
if (userMessage?.info.role === "user") {
expect(userMessage.info.format).toBeDefined()
expect(userMessage.info.format?.type).toBe("json_schema")
if (userMessage.info.format?.type === "json_schema") {
expect(userMessage.info.format.retryCount).toBe(3)
}
}
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
})
// Clean up
// Note: Not removing session to avoid race with background SessionSummary.summarize
}),
),
)
},
60000,
)
+2 -2
View File
@@ -4,7 +4,7 @@ import { Cause, Effect, Exit, Layer } from "effect"
import { GlobTool } from "../../src/tool/glob"
import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Search } from "../../src/file/search"
import { Ripgrep } from "../../src/file/ripgrep"
import { AppFileSystem } from "../../src/filesystem"
import { Truncate } from "../../src/tool/truncate"
import { Agent } from "../../src/agent/agent"
@@ -15,7 +15,7 @@ const it = testEffect(
Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
AppFileSystem.defaultLayer,
Search.defaultLayer,
Ripgrep.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
),
+5 -5
View File
@@ -7,7 +7,7 @@ import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Truncate } from "../../src/tool/truncate"
import { Agent } from "../../src/agent/agent"
import { Search } from "../../src/file/search"
import { Ripgrep } from "../../src/file/ripgrep"
import { AppFileSystem } from "../../src/filesystem"
import { testEffect } from "../lib/effect"
@@ -15,7 +15,7 @@ const it = testEffect(
Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
AppFileSystem.defaultLayer,
Search.defaultLayer,
Ripgrep.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
),
@@ -32,18 +32,18 @@ const ctx = {
ask: () => Effect.void,
}
const root = path.join(__dirname, "../..")
const projectRoot = path.join(__dirname, "../..")
describe("tool.grep", () => {
it.live("basic search", () =>
Effect.gen(function* () {
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* provideInstance(root)(
const result = yield* provideInstance(projectRoot)(
grep.execute(
{
pattern: "export",
path: path.join(root, "src/tool"),
path: path.join(projectRoot, "src/tool"),
include: "*.ts",
},
ctx,
-2
View File
@@ -4,7 +4,6 @@ import path from "path"
import { Agent } from "../../src/agent/agent"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { AppFileSystem } from "../../src/filesystem"
import { Search } from "../../src/file/search"
import { FileTime } from "../../src/file/time"
import { LSP } from "../../src/lsp"
import { Permission } from "../../src/permission"
@@ -43,7 +42,6 @@ const it = testEffect(
FileTime.defaultLayer,
Instruction.defaultLayer,
LSP.defaultLayer,
Search.defaultLayer,
Truncate.defaultLayer,
),
)
+43 -40
View File
@@ -1,6 +1,10 @@
import { Effect, Layer, ManagedRuntime } from "effect"
import { Agent } from "../../src/agent/agent"
import { Skill } from "../../src/skill"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Effect, Layer } from "effect"
import { afterEach, describe, expect } from "bun:test"
import { Ripgrep } from "../../src/file/ripgrep"
import { Truncate } from "../../src/tool/truncate"
import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
import type { Permission } from "../../src/permission"
@@ -8,7 +12,7 @@ import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance"
import { SkillTool } from "../../src/tool/skill"
import { ToolRegistry } from "../../src/tool/registry"
import { provideTmpdirInstance } from "../fixture/fixture"
import { provideTmpdirInstance, tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
import { testEffect } from "../lib/effect"
@@ -127,15 +131,14 @@ description: ${description}
),
)
it.live("execute returns skill content block with files", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const skill = path.join(dir, ".opencode", "skill", "tool-skill")
yield* Effect.promise(() =>
Bun.write(
path.join(skill, "SKILL.md"),
`---
test("execute returns skill content block with files", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const skillDir = path.join(dir, ".opencode", "skill", "tool-skill")
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: tool-skill
description: Skill for tool tests.
---
@@ -144,27 +147,23 @@ description: Skill for tool tests.
Use this skill.
`,
),
)
await Bun.write(path.join(skillDir, "scripts", "demo.txt"), "demo")
},
})
const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = tmp.path
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const runtime = ManagedRuntime.make(
Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer),
)
yield* Effect.promise(() => Bun.write(path.join(skill, "scripts", "demo.txt"), "demo"))
const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = dir
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
process.env.OPENCODE_TEST_HOME = home
}),
)
const registry = yield* ToolRegistry.Service
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
const tool = (yield* registry.tools({
providerID: "opencode" as any,
modelID: "gpt-5" as any,
agent,
})).find((tool) => tool.id === SkillTool.id)
if (!tool) throw new Error("Skill tool not found")
const info = await runtime.runPromise(SkillTool)
const tool = await runtime.runPromise(info.init())
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
@@ -174,19 +173,23 @@ Use this skill.
}),
}
const result = yield* tool.execute({ name: "tool-skill" }, ctx)
const file = path.resolve(skill, "scripts", "demo.txt")
const result = await runtime.runPromise(tool.execute({ name: "tool-skill" }, ctx))
const dir = path.join(tmp.path, ".opencode", "skill", "tool-skill")
const file = path.resolve(dir, "scripts", "demo.txt")
expect(requests.length).toBe(1)
expect(requests[0].permission).toBe("skill")
expect(requests[0].patterns).toContain("tool-skill")
expect(requests[0].always).toContain("tool-skill")
expect(result.metadata.dir).toBe(skill)
expect(result.metadata.dir).toBe(dir)
expect(result.output).toContain(`<skill_content name="tool-skill">`)
expect(result.output).toContain(`Base directory for this skill: ${pathToFileURL(skill).href}`)
expect(result.output).toContain(`Base directory for this skill: ${pathToFileURL(dir).href}`)
expect(result.output).toContain(`<file>${file}</file>`)
}),
{ git: true },
),
)
},
})
} finally {
process.env.OPENCODE_TEST_HOME = home
}
})
})
+1 -1
View File
@@ -76,7 +76,7 @@ function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void;
}
}
function reply(input: Parameters<typeof SessionPrompt.prompt>[0], text: string): MessageV2.WithParts {
function reply(input: SessionPrompt.PromptInput, text: string): MessageV2.WithParts {
const id = MessageID.ascending()
return {
info: {