Compare commits

..

9 Commits

Author SHA1 Message Date
Kit Langton
babb46327f refactor(effect): effectify task tool execution
Move the task tool body onto named Effect helpers and keep the Promise bridge at the outer init/execute boundaries. This matches the read tool migration shape while preserving the existing SessionPrompt runtime flow.
2026-04-04 12:14:42 -04:00
Kit Langton
78f7258c6d refactor(effect): build task tool from agent services 2026-04-04 12:14:42 -04:00
Kit Langton
baff53b759 refactor(effect): keep read path handling in app filesystem
Move the repaired Windows path normalization and not-found handling back behind AppFileSystem helpers so the read tool stays on the service abstraction end-to-end. Keep external-directory checks on the same path helper family for consistency.
2026-04-04 12:14:11 -04:00
Kit Langton
b15f1593c0 refactor(effect): scope read tool warmup
Capture Scope.Scope in the read tool effect and fork LSP warmup into that scope instead of using runFork inside Effect.sync. This keeps the fire-and-forget behavior while matching the surrounding Effect patterns.
2026-04-04 12:01:04 -04:00
Kit Langton
98384cd860 test(effect): simplify read tool harness
Reduce helper indirection in the read tool tests by adding a scope-local run helper, reusing a shared permission capture helper, and keeping env-permission assertions inside a single instance scope.
2026-04-04 12:01:04 -04:00
Kit Langton
6a56bd5e79 refactor(effect): simplify read tool boundaries
Keep LSP warmup off the read critical path and use AppFileSystem helpers directly in the read tool. Update the migration note to describe the single-bridge pattern that the tool now follows.
2026-04-04 12:01:04 -04:00
Kit Langton
d9a07b5d96 refactor(effect): effectify read tool execution
Move the read tool body onto a named Effect path and keep a single Promise bridge at execute(). This keeps the service graph wiring from the previous commit while reducing runPromise islands inside the tool implementation.
2026-04-04 12:01:04 -04:00
Kit Langton
64f6c66984 refactor(effect): wire read tool through services
Yield AppFileSystem, Instruction, LSP, and FileTime from the read tool effect so the tool closes over real services instead of static facades. Rewrite read tool tests to use the effect test harness and document the migration pattern for effectified tools.
2026-04-04 11:59:21 -04:00
Kit Langton
62f1421120 refactor(effect): move read tool onto defineEffect 2026-04-04 11:57:53 -04:00
32 changed files with 1082 additions and 1012 deletions

View File

@@ -75,7 +75,6 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
include-hidden-files: true
if-no-files-found: ignore
retention-days: 7
path: packages/*/.artifacts/unit/junit.xml

View File

@@ -26,7 +26,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -80,7 +80,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -114,7 +114,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -141,7 +141,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -165,7 +165,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -189,7 +189,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -222,7 +222,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -254,7 +254,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -283,7 +283,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -299,7 +299,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.3.15",
"version": "1.3.13",
"bin": {
"opencode": "./bin/opencode",
},
@@ -428,7 +428,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -462,7 +462,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -477,7 +477,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -512,7 +512,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -560,7 +560,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"zod": "catalog:",
},
@@ -571,7 +571,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.3.15",
"version": "1.3.13",
"description": "",
"type": "module",
"exports": {
@@ -15,7 +15,7 @@
"build": "vite build",
"serve": "vite preview",
"test": "bun run test:unit",
"test:ci": "mkdir -p .artifacts/unit && bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"test:ci": "bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"test:unit": "bun test --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.3.15",
"version": "1.3.13",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.3.15",
"version": "1.3.13",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.3.15",
"version": "1.3.13",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.3.15",
"version": "1.3.13",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.3.15",
"version": "1.3.13",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.3.15",
"version": "1.3.13",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.3.15",
"version": "1.3.13",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.3.15"
version = "1.3.13"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.3.15",
"version": "1.3.13",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.3.15",
"version": "1.3.13",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -9,7 +9,7 @@
"prepare": "effect-language-service patch || true",
"typecheck": "tsgo --noEmit",
"test": "bun test --timeout 30000",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"test:ci": "bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"build": "bun run script/build.ts",
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"dev": "bun run --conditions=browser ./src/index.ts",

View File

@@ -209,7 +209,6 @@ for (const item of targets) {
conditions: ["browser"],
tsconfig: "./tsconfig.json",
plugins: [plugin],
external: ["node-gyp"],
compile: {
autoloadBunfig: false,
autoloadDotenv: false,

View File

@@ -235,6 +235,22 @@ Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `i
2. Update `Tool.define()` factory to work with Effects
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing
### Tool migration details
Until the tool interface itself returns `Effect`, use this transitional pattern for migrated tools:
- `Tool.defineEffect(...)` should `yield*` the services the tool depends on and close over them in the returned tool definition.
- Keep the bridge at the Promise boundary only. Prefer a single `Effect.runPromise(...)` in the temporary `async execute(...)` implementation, and move the inner logic into `Effect.fn(...)` helpers instead of scattering `runPromise` islands through the tool body.
- If a tool starts requiring new services, wire them into `ToolRegistry.defaultLayer` so production callers resolve the same dependencies as tests.
Tool tests should use the existing Effect helpers in `packages/opencode/test/lib/effect.ts`:
- Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools.
- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* Effect.promise(() => info.init())`.
- Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production.
This keeps migrated tool tests aligned with the production service graph today, and makes the eventual `Tool.Info``Effect` cleanup mostly mechanical later.
Individual tools, ordered by value:
- [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events

View File

@@ -188,13 +188,23 @@ export namespace AppFileSystem {
export function normalizePath(p: string): string {
if (process.platform !== "win32") return p
const resolved = pathResolve(windowsPath(p))
try {
return realpathSync.native(p)
return realpathSync.native(resolved)
} catch {
return p
return resolved
}
}
export function normalizePathPattern(p: string): string {
if (process.platform !== "win32") return p
if (p === "*") return p
const match = p.match(/^(.*)[\\/]\*$/)
if (!match) return normalizePath(p)
const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1]
return join(normalizePath(dir), "*")
}
export function resolve(p: string): string {
const resolved = pathResolve(windowsPath(p))
try {

View File

@@ -67,7 +67,6 @@ export namespace Npm {
binLinks: true,
progress: false,
savePrefix: "",
ignoreScripts: true,
})
const tree = await arborist.loadVirtual().catch(() => {})
if (tree) {
@@ -107,7 +106,6 @@ export namespace Npm {
binLinks: true,
progress: false,
savePrefix: "",
ignoreScripts: true,
})
await arb.reify().catch(() => {})
}

View File

@@ -82,6 +82,25 @@ If the `AGENTS.md` is empty or insufficient, you may check `README`/`README.md`
If you modified any files/styles/structures/configurations/workflows/... mentioned in `AGENTS.md` files, you MUST update the corresponding `AGENTS.md` files to keep them up-to-date.
# Skills
Skills are reusable, composable capabilities that enhance your abilities. Each skill is a self-contained directory with a `SKILL.md` file that contains instructions, examples, and/or reference material.
## What are skills?
Skills are modular extensions that provide:
- Specialized knowledge: Domain-specific expertise (e.g., PDF processing, data analysis)
- Workflow patterns: Best practices for common tasks
- Tool integrations: Pre-configured tool chains for specific operations
- Reference material: Documentation, templates, and examples
## How to use skills
Identify the skills that are likely to be useful for the tasks you are currently working on, use the `skill` tool to load a skill for detailed instructions, guidelines, scripts and more.
Only load skill details when needed to conserve the context window.
# Ultimate Reminders
At any time, you should be HELPFUL, CONCISE, and ACCURATE. Be thorough in your actions — test what you build, verify what you change — not in your explanations.

View File

@@ -1,7 +1,8 @@
import path from "path"
import { Effect } from "effect"
import type { Tool } from "./tool"
import { Instance } from "../project/instance"
import { Filesystem } from "@/util/filesystem"
import { AppFileSystem } from "../filesystem"
type Kind = "file" | "directory"
@@ -15,14 +16,14 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string
if (options?.bypass) return
const full = process.platform === "win32" ? Filesystem.normalizePath(target) : target
const full = process.platform === "win32" ? AppFileSystem.normalizePath(target) : target
if (Instance.containsPath(full)) return
const kind = options?.kind ?? "file"
const dir = kind === "directory" ? full : path.dirname(full)
const glob =
process.platform === "win32"
? Filesystem.normalizePathPattern(path.join(dir, "*"))
? AppFileSystem.normalizePathPattern(path.join(dir, "*"))
: path.join(dir, "*").replaceAll("\\", "/")
await ctx.ask({
@@ -35,3 +36,11 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string
},
})
}
export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirectory")(function* (
ctx: Tool.Context,
target?: string,
options?: Options,
) {
yield* Effect.promise(() => assertExternalDirectory(ctx, target, options))
})

View File

@@ -1,16 +1,17 @@
import z from "zod"
import { Effect, Scope } from "effect"
import { createReadStream } from "fs"
import * as fs from "fs/promises"
import { open } from "fs/promises"
import * as path from "path"
import { createInterface } from "readline"
import { Tool } from "./tool"
import { AppFileSystem } from "../filesystem"
import { LSP } from "../lsp"
import { FileTime } from "../file/time"
import DESCRIPTION from "./read.txt"
import { Instance } from "../project/instance"
import { assertExternalDirectory } from "./external-directory"
import { assertExternalDirectoryEffect } from "./external-directory"
import { Instruction } from "../session/instruction"
import { Filesystem } from "../util/filesystem"
const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
@@ -18,222 +19,257 @@ const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
const MAX_BYTES = 50 * 1024
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
export const ReadTool = Tool.define("read", {
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file or directory to read"),
offset: z.coerce.number().describe("The line number to start reading from (1-indexed)").optional(),
limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(),
}),
async execute(params, ctx) {
if (params.offset !== undefined && params.offset < 1) {
throw new Error("offset must be greater than or equal to 1")
}
let filepath = params.filePath
if (!path.isAbsolute(filepath)) {
filepath = path.resolve(Instance.directory, filepath)
}
if (process.platform === "win32") {
filepath = Filesystem.normalizePath(filepath)
}
const title = path.relative(Instance.worktree, filepath)
const parameters = z.object({
filePath: z.string().describe("The absolute path to the file or directory to read"),
offset: z.coerce.number().describe("The line number to start reading from (1-indexed)").optional(),
limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(),
})
const stat = Filesystem.stat(filepath)
export const ReadTool = Tool.defineEffect(
"read",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const instruction = yield* Instruction.Service
const lsp = yield* LSP.Service
const time = yield* FileTime.Service
const scope = yield* Scope.Scope
await assertExternalDirectory(ctx, filepath, {
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
kind: stat?.isDirectory() ? "directory" : "file",
})
await ctx.ask({
permission: "read",
patterns: [filepath],
always: ["*"],
metadata: {},
})
if (!stat) {
const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) {
const dir = path.dirname(filepath)
const base = path.basename(filepath)
const suggestions = await fs
.readdir(dir)
.then((entries) =>
entries
const items = yield* fs.readDirectory(dir).pipe(
Effect.map((items) =>
items
.filter(
(entry) =>
entry.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(entry.toLowerCase()),
(item) =>
item.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(item.toLowerCase()),
)
.map((entry) => path.join(dir, entry))
.map((item) => path.join(dir, item))
.slice(0, 3),
)
.catch(() => [])
),
Effect.catch(() => Effect.succeed([] as string[])),
)
if (suggestions.length > 0) {
throw new Error(`File not found: ${filepath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`)
if (items.length > 0) {
return yield* Effect.fail(
new Error(`File not found: ${filepath}\n\nDid you mean one of these?\n${items.join("\n")}`),
)
}
throw new Error(`File not found: ${filepath}`)
}
return yield* Effect.fail(new Error(`File not found: ${filepath}`))
})
if (stat.isDirectory()) {
const dirents = await fs.readdir(filepath, { withFileTypes: true })
const entries = await Promise.all(
dirents.map(async (dirent) => {
if (dirent.isDirectory()) return dirent.name + "/"
if (dirent.isSymbolicLink()) {
const target = await fs.stat(path.join(filepath, dirent.name)).catch(() => undefined)
if (target?.isDirectory()) return dirent.name + "/"
}
return dirent.name
const list = Effect.fn("ReadTool.list")(function* (filepath: string) {
const items = yield* fs.readDirectoryEntries(filepath)
return yield* Effect.forEach(
items,
Effect.fnUntraced(function* (item) {
if (item.type === "directory") return item.name + "/"
if (item.type !== "symlink") return item.name
const target = yield* fs
.stat(path.join(filepath, item.name))
.pipe(Effect.catch(() => Effect.succeed(undefined)))
if (target?.type === "Directory") return item.name + "/"
return item.name
}),
{ concurrency: "unbounded" },
).pipe(Effect.map((items: string[]) => items.sort((a, b) => a.localeCompare(b))))
})
const warm = Effect.fn("ReadTool.warm")(function* (filepath: string, sessionID: Tool.Context["sessionID"]) {
yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope))
yield* time.read(sessionID, filepath)
})
const run = Effect.fn("ReadTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
if (params.offset !== undefined && params.offset < 1) {
return yield* Effect.fail(new Error("offset must be greater than or equal to 1"))
}
let filepath = params.filePath
if (!path.isAbsolute(filepath)) {
filepath = path.resolve(Instance.directory, filepath)
}
if (process.platform === "win32") {
filepath = AppFileSystem.normalizePath(filepath)
}
const title = path.relative(Instance.worktree, filepath)
const stat = yield* fs.stat(filepath).pipe(
Effect.catchIf(
(err) => "reason" in err && err.reason._tag === "NotFound",
() => Effect.succeed(undefined),
),
)
yield* assertExternalDirectoryEffect(ctx, filepath, {
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
kind: stat?.type === "Directory" ? "directory" : "file",
})
yield* Effect.promise(() =>
ctx.ask({
permission: "read",
patterns: [filepath],
always: ["*"],
metadata: {},
}),
)
entries.sort((a, b) => a.localeCompare(b))
const limit = params.limit ?? DEFAULT_READ_LIMIT
const offset = params.offset ?? 1
const start = offset - 1
const sliced = entries.slice(start, start + limit)
const truncated = start + sliced.length < entries.length
if (!stat) return yield* miss(filepath)
const output = [
`<path>${filepath}</path>`,
`<type>directory</type>`,
`<entries>`,
sliced.join("\n"),
truncated
? `\n(Showing ${sliced.length} of ${entries.length} entries. Use 'offset' parameter to read beyond entry ${offset + sliced.length})`
: `\n(${entries.length} entries)`,
`</entries>`,
].join("\n")
if (stat.type === "Directory") {
const items = yield* list(filepath)
const limit = params.limit ?? DEFAULT_READ_LIMIT
const offset = params.offset ?? 1
const start = offset - 1
const sliced = items.slice(start, start + limit)
const truncated = start + sliced.length < items.length
return {
title,
output: [
`<path>${filepath}</path>`,
`<type>directory</type>`,
`<entries>`,
sliced.join("\n"),
truncated
? `\n(Showing ${sliced.length} of ${items.length} entries. Use 'offset' parameter to read beyond entry ${offset + sliced.length})`
: `\n(${items.length} entries)`,
`</entries>`,
].join("\n"),
metadata: {
preview: sliced.slice(0, 20).join("\n"),
truncated,
loaded: [] as string[],
},
}
}
const loaded = yield* instruction.resolve(ctx.messages, filepath, ctx.messageID)
const mime = AppFileSystem.mimeType(filepath)
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
const isPdf = mime === "application/pdf"
if (isImage || isPdf) {
const msg = `${isImage ? "Image" : "PDF"} read successfully`
return {
title,
output: msg,
metadata: {
preview: msg,
truncated: false,
loaded: loaded.map((item) => item.filepath),
},
attachments: [
{
type: "file" as const,
mime,
url: `data:${mime};base64,${Buffer.from(yield* fs.readFile(filepath)).toString("base64")}`,
},
],
}
}
if (yield* Effect.promise(() => isBinaryFile(filepath, Number(stat.size)))) {
return yield* Effect.fail(new Error(`Cannot read binary file: ${filepath}`))
}
const file = yield* Effect.promise(() =>
lines(filepath, { limit: params.limit ?? DEFAULT_READ_LIMIT, offset: params.offset ?? 1 }),
)
if (file.count < file.offset && !(file.count === 0 && file.offset === 1)) {
return yield* Effect.fail(
new Error(`Offset ${file.offset} is out of range for this file (${file.count} lines)`),
)
}
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>"].join("\n")
output += file.raw.map((line, i) => `${i + file.offset}: ${line}`).join("\n")
const last = file.offset + file.raw.length - 1
const next = last + 1
const truncated = file.more || file.cut
if (file.cut) {
output += `\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${file.offset}-${last}. Use offset=${next} to continue.)`
} else if (file.more) {
output += `\n\n(Showing lines ${file.offset}-${last} of ${file.count}. Use offset=${next} to continue.)`
} else {
output += `\n\n(End of file - total ${file.count} lines)`
}
output += "\n</content>"
yield* warm(filepath, ctx.sessionID)
if (loaded.length > 0) {
output += `\n\n<system-reminder>\n${loaded.map((item) => item.content).join("\n\n")}\n</system-reminder>`
}
return {
title,
output,
metadata: {
preview: sliced.slice(0, 20).join("\n"),
preview: file.raw.slice(0, 20).join("\n"),
truncated,
loaded: [] as string[],
loaded: loaded.map((item) => item.filepath),
},
}
}
const instructions = await Instruction.resolve(ctx.messages, filepath, ctx.messageID)
// Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files)
const mime = Filesystem.mimeType(filepath)
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
const isPdf = mime === "application/pdf"
if (isImage || isPdf) {
const msg = `${isImage ? "Image" : "PDF"} read successfully`
return {
title,
output: msg,
metadata: {
preview: msg,
truncated: false,
loaded: instructions.map((i) => i.filepath),
},
attachments: [
{
type: "file",
mime,
url: `data:${mime};base64,${Buffer.from(await Filesystem.readBytes(filepath)).toString("base64")}`,
},
],
}
}
const isBinary = await isBinaryFile(filepath, Number(stat.size))
if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`)
const stream = createReadStream(filepath, { encoding: "utf8" })
const rl = createInterface({
input: stream,
// Note: we use the crlfDelay option to recognize all instances of CR LF
// ('\r\n') in file as a single line break.
crlfDelay: Infinity,
})
const limit = params.limit ?? DEFAULT_READ_LIMIT
const offset = params.offset ?? 1
const start = offset - 1
const raw: string[] = []
let bytes = 0
let lines = 0
let truncatedByBytes = false
let hasMoreLines = false
try {
for await (const text of rl) {
lines += 1
if (lines <= start) continue
if (raw.length >= limit) {
hasMoreLines = true
continue
}
const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text
const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)
if (bytes + size > MAX_BYTES) {
truncatedByBytes = true
hasMoreLines = true
break
}
raw.push(line)
bytes += size
}
} finally {
rl.close()
stream.destroy()
}
if (lines < offset && !(lines === 0 && offset === 1)) {
throw new Error(`Offset ${offset} is out of range for this file (${lines} lines)`)
}
const content = raw.map((line, index) => {
return `${index + offset}: ${line}`
})
const preview = raw.slice(0, 20).join("\n")
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>"].join("\n")
output += content.join("\n")
const totalLines = lines
const lastReadLine = offset + raw.length - 1
const nextOffset = lastReadLine + 1
const truncated = hasMoreLines || truncatedByBytes
if (truncatedByBytes) {
output += `\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${offset}-${lastReadLine}. Use offset=${nextOffset} to continue.)`
} else if (hasMoreLines) {
output += `\n\n(Showing lines ${offset}-${lastReadLine} of ${totalLines}. Use offset=${nextOffset} to continue.)`
} else {
output += `\n\n(End of file - total ${totalLines} lines)`
}
output += "\n</content>"
// just warms the lsp client
LSP.touchFile(filepath, false)
await FileTime.read(ctx.sessionID, filepath)
if (instructions.length > 0) {
output += `\n\n<system-reminder>\n${instructions.map((i) => i.content).join("\n\n")}\n</system-reminder>`
}
return {
title,
output,
metadata: {
preview,
truncated,
loaded: instructions.map((i) => i.filepath),
description: DESCRIPTION,
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
return Effect.runPromise(run(params, ctx).pipe(Effect.orDie))
},
}
},
})
}),
)
async function lines(filepath: string, opts: { limit: number; offset: number }) {
const stream = createReadStream(filepath, { encoding: "utf8" })
const rl = createInterface({
input: stream,
// Note: we use the crlfDelay option to recognize all instances of CR LF
// ('\r\n') in file as a single line break.
crlfDelay: Infinity,
})
const start = opts.offset - 1
const raw: string[] = []
let bytes = 0
let count = 0
let cut = false
let more = false
try {
for await (const text of rl) {
count += 1
if (count <= start) continue
if (raw.length >= opts.limit) {
more = true
continue
}
const line = text.length > MAX_LINE_LENGTH ? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text
const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)
if (bytes + size > MAX_BYTES) {
cut = true
more = true
break
}
raw.push(line)
bytes += size
}
} finally {
rl.close()
stream.destroy()
}
return { raw, count, cut, more, offset: opts.offset }
}
async function isBinaryFile(filepath: string, fileSize: number): Promise<boolean> {
const ext = path.extname(filepath).toLowerCase()
@@ -274,7 +310,7 @@ async function isBinaryFile(filepath: string, fileSize: number): Promise<boolean
if (fileSize === 0) return false
const fh = await fs.open(filepath, "r")
const fh = await open(filepath, "r")
try {
const sampleSize = Math.min(4096, fileSize)
const bytes = Buffer.alloc(sampleSize)

View File

@@ -33,8 +33,13 @@ import { Effect, Layer, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Env } from "../env"
import { Agent as AgentSvc } from "../agent/agent"
import { Question } from "../question"
import { Todo } from "../session/todo"
import { LSP } from "../lsp"
import { FileTime } from "../file/time"
import { Instruction } from "../session/instruction"
import { AppFileSystem } from "../filesystem"
export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
@@ -57,175 +62,190 @@ export namespace ToolRegistry {
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {}
export const layer: Layer.Layer<Service, never, Config.Service | Plugin.Service | Question.Service | Todo.Service> =
Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const plugin = yield* Plugin.Service
export const layer: Layer.Layer<
Service,
never,
| Config.Service
| Plugin.Service
| Question.Service
| Todo.Service
| AgentSvc.Service
| LSP.Service
| FileTime.Service
| Instruction.Service
| AppFileSystem.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const plugin = yield* Plugin.Service
const build = <T extends Tool.Info>(tool: T | Effect.Effect<T, never, any>) =>
Effect.isEffect(tool) ? tool : Effect.succeed(tool)
const build = <T extends Tool.Info>(tool: T | Effect.Effect<T, never, any>) =>
Effect.isEffect(tool) ? tool : Effect.succeed(tool)
const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) {
const custom: Tool.Info[] = []
const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) {
const custom: Tool.Info[] = []
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
return {
id,
init: async (initCtx) => ({
parameters: z.object(def.args),
description: def.description,
execute: async (args, toolCtx) => {
const pluginCtx = {
...toolCtx,
directory: ctx.directory,
worktree: ctx.worktree,
} as unknown as PluginToolContext
const result = await def.execute(args as any, pluginCtx)
const out = await Truncate.output(result, {}, initCtx?.agent)
return {
title: "",
output: out.truncated ? out.content : result,
metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
}
},
}),
}
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
return {
id,
init: async (initCtx) => ({
parameters: z.object(def.args),
description: def.description,
execute: async (args, toolCtx) => {
const pluginCtx = {
...toolCtx,
directory: ctx.directory,
worktree: ctx.worktree,
} as unknown as PluginToolContext
const result = await def.execute(args as any, pluginCtx)
const out = await Truncate.output(result, {}, initCtx?.agent)
return {
title: "",
output: out.truncated ? out.content : result,
metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
}
},
}),
}
}
const dirs = yield* config.directories()
const matches = dirs.flatMap((dir) =>
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
)
if (matches.length) yield* config.waitForDependencies()
for (const match of matches) {
const namespace = path.basename(match, path.extname(match))
const mod = yield* Effect.promise(
() => import(process.platform === "win32" ? match : pathToFileURL(match).href),
)
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
}
}
const plugins = yield* plugin.list()
for (const p of plugins) {
for (const [id, def] of Object.entries(p.tool ?? {})) {
custom.push(fromPlugin(id, def))
}
}
return { custom }
}),
)
const invalid = yield* build(InvalidTool)
const ask = yield* build(QuestionTool)
const bash = yield* build(BashTool)
const read = yield* build(ReadTool)
const glob = yield* build(GlobTool)
const grep = yield* build(GrepTool)
const edit = yield* build(EditTool)
const write = yield* build(WriteTool)
const task = yield* build(TaskTool)
const fetch = yield* build(WebFetchTool)
const todo = yield* build(TodoWriteTool)
const search = yield* build(WebSearchTool)
const code = yield* build(CodeSearchTool)
const skill = yield* build(SkillTool)
const patch = yield* build(ApplyPatchTool)
const lsp = yield* build(LspTool)
const batch = yield* build(BatchTool)
const plan = yield* build(PlanExitTool)
const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) {
const cfg = yield* config.get()
const question =
["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
return [
invalid,
...(question ? [ask] : []),
bash,
read,
glob,
grep,
edit,
write,
task,
fetch,
todo,
search,
code,
skill,
patch,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [lsp] : []),
...(cfg.experimental?.batch_tool === true ? [batch] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [plan] : []),
...custom,
]
})
const ids = Effect.fn("ToolRegistry.ids")(function* () {
const s = yield* InstanceState.get(state)
const tools = yield* all(s.custom)
return tools.map((t) => t.id)
})
const tools = Effect.fn("ToolRegistry.tools")(function* (
model: { providerID: ProviderID; modelID: ModelID },
agent?: Agent.Info,
) {
const s = yield* InstanceState.get(state)
const allTools = yield* all(s.custom)
const filtered = allTools.filter((tool) => {
if (tool.id === "codesearch" || tool.id === "websearch") {
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
}
const usePatch =
!!Env.get("OPENCODE_E2E_LLM_URL") ||
(model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4"))
if (tool.id === "apply_patch") return usePatch
if (tool.id === "edit" || tool.id === "write") return !usePatch
return true
})
return yield* Effect.forEach(
filtered,
Effect.fnUntraced(function* (tool: Tool.Info) {
using _ = log.time(tool.id)
const next = yield* Effect.promise(() => tool.init({ agent }))
const output = {
description: next.description,
parameters: next.parameters,
}
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
return {
id: tool.id,
description: output.description,
parameters: output.parameters,
execute: next.execute,
formatValidationError: next.formatValidationError,
}
}),
{ concurrency: "unbounded" },
const dirs = yield* config.directories()
const matches = dirs.flatMap((dir) =>
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
)
})
if (matches.length) yield* config.waitForDependencies()
for (const match of matches) {
const namespace = path.basename(match, path.extname(match))
const mod = yield* Effect.promise(
() => import(process.platform === "win32" ? match : pathToFileURL(match).href),
)
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
}
}
return Service.of({ ids, named: { task, read }, tools })
}),
)
const plugins = yield* plugin.list()
for (const p of plugins) {
for (const [id, def] of Object.entries(p.tool ?? {})) {
custom.push(fromPlugin(id, def))
}
}
return { custom }
}),
)
const invalid = yield* build(InvalidTool)
const ask = yield* build(QuestionTool)
const bash = yield* build(BashTool)
const read = yield* build(ReadTool)
const glob = yield* build(GlobTool)
const grep = yield* build(GrepTool)
const edit = yield* build(EditTool)
const write = yield* build(WriteTool)
const task = yield* build(TaskTool)
const fetch = yield* build(WebFetchTool)
const todo = yield* build(TodoWriteTool)
const search = yield* build(WebSearchTool)
const code = yield* build(CodeSearchTool)
const skill = yield* build(SkillTool)
const patch = yield* build(ApplyPatchTool)
const lsp = yield* build(LspTool)
const batch = yield* build(BatchTool)
const plan = yield* build(PlanExitTool)
const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) {
const cfg = yield* config.get()
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
return [
invalid,
...(question ? [ask] : []),
bash,
read,
glob,
grep,
edit,
write,
task,
fetch,
todo,
search,
code,
skill,
patch,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [lsp] : []),
...(cfg.experimental?.batch_tool === true ? [batch] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [plan] : []),
...custom,
]
})
const ids = Effect.fn("ToolRegistry.ids")(function* () {
const s = yield* InstanceState.get(state)
const tools = yield* all(s.custom)
return tools.map((t) => t.id)
})
const tools = Effect.fn("ToolRegistry.tools")(function* (
model: { providerID: ProviderID; modelID: ModelID },
agent?: Agent.Info,
) {
const s = yield* InstanceState.get(state)
const allTools = yield* all(s.custom)
const filtered = allTools.filter((tool) => {
if (tool.id === "codesearch" || tool.id === "websearch") {
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
}
const usePatch =
!!Env.get("OPENCODE_E2E_LLM_URL") ||
(model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4"))
if (tool.id === "apply_patch") return usePatch
if (tool.id === "edit" || tool.id === "write") return !usePatch
return true
})
return yield* Effect.forEach(
filtered,
Effect.fnUntraced(function* (tool: Tool.Info) {
using _ = log.time(tool.id)
const next = yield* Effect.promise(() => tool.init({ agent }))
const output = {
description: next.description,
parameters: next.parameters,
}
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
return {
id: tool.id,
description: output.description,
parameters: output.parameters,
execute: next.execute,
formatValidationError: next.formatValidationError,
}
}),
{ concurrency: "unbounded" },
)
})
return Service.of({ ids, named: { task, read }, tools })
}),
)
export const defaultLayer = Layer.unwrap(
Effect.sync(() =>
layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(AgentSvc.defaultLayer),
Layer.provide(Question.defaultLayer),
Layer.provide(Todo.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(FileTime.defaultLayer),
Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
),
),
)

View File

@@ -1,14 +1,13 @@
import { Tool } from "./tool"
import DESCRIPTION from "./task.txt"
import z from "zod"
import { Effect } from "effect"
import { Session } from "../session"
import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2"
import { Identifier } from "../id/id"
import { Agent } from "../agent/agent"
import { SessionPrompt } from "../session/prompt"
import { iife } from "@/util/iife"
import { defer } from "@/util/defer"
import { Config } from "../config/config"
import { Permission } from "@/permission"
@@ -25,87 +24,101 @@ const parameters = z.object({
command: z.string().describe("The command that triggered this task").optional(),
})
export const TaskTool = Tool.define("task", async (ctx) => {
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
export const TaskTool = Tool.defineEffect(
"task",
Effect.gen(function* () {
const agent = yield* Agent.Service
const config = yield* Config.Service
// Filter agents by permissions if agent provided
const caller = ctx?.agent
const accessibleAgents = caller
? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny")
: agents
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
const list = Effect.fn("TaskTool.list")(function* (caller?: Tool.InitContext["agent"]) {
const items = yield* agent.list().pipe(Effect.map((items) => items.filter((item) => item.mode !== "primary")))
const filtered = caller
? items.filter((item) => Permission.evaluate("task", item.name, caller.permission).action !== "deny")
: items
return filtered.toSorted((a, b) => a.name.localeCompare(b.name))
})
const description = DESCRIPTION.replace(
"{agents}",
list
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n"),
)
return {
description,
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
const config = await Config.get()
const desc = Effect.fn("TaskTool.desc")(function* (caller?: Tool.InitContext["agent"]) {
const items = yield* list(caller)
return DESCRIPTION.replace(
"{agents}",
items
.map(
(item) =>
`- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`,
)
.join("\n"),
)
})
const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
const cfg = yield* config.get()
// Skip permission check when user explicitly invoked via @ or command subtask
if (!ctx.extra?.bypassAgentCheck) {
await ctx.ask({
permission: "task",
patterns: [params.subagent_type],
always: ["*"],
metadata: {
description: params.description,
subagent_type: params.subagent_type,
},
})
yield* Effect.promise(() =>
ctx.ask({
permission: "task",
patterns: [params.subagent_type],
always: ["*"],
metadata: {
description: params.description,
subagent_type: params.subagent_type,
},
}),
)
}
const agent = await Agent.get(params.subagent_type)
if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
const next = yield* agent.get(params.subagent_type).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!next) {
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
}
const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task")
const hasTodoWritePermission = agent.permission.some((rule) => rule.permission === "todowrite")
const hasTask = next.permission.some((rule) => rule.permission === "task")
const hasTodo = next.permission.some((rule) => rule.permission === "todowrite")
const session = await iife(async () => {
if (params.task_id) {
const found = await Session.get(SessionID.make(params.task_id)).catch(() => {})
if (found) return found
}
const session = yield* Effect.promise(() =>
iife(async () => {
if (params.task_id) {
const found = await Session.get(SessionID.make(params.task_id)).catch(() => {})
if (found) return found
}
return await Session.create({
parentID: ctx.sessionID,
title: params.description + ` (@${agent.name} subagent)`,
permission: [
...(hasTodoWritePermission
? []
: [
{
permission: "todowrite" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(hasTaskPermission
? []
: [
{
permission: "task" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(config.experimental?.primary_tools?.map((t) => ({
pattern: "*",
action: "allow" as const,
permission: t,
})) ?? []),
],
})
})
const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID })
if (msg.info.role !== "assistant") throw new Error("Not an assistant message")
return Session.create({
parentID: ctx.sessionID,
title: params.description + ` (@${next.name} subagent)`,
permission: [
...(hasTodo
? []
: [
{
permission: "todowrite" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(hasTask
? []
: [
{
permission: "task" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(cfg.experimental?.primary_tools?.map((item) => ({
pattern: "*",
action: "allow" as const,
permission: item,
})) ?? []),
],
})
}),
)
const model = agent.model ?? {
const msg = yield* Effect.sync(() => MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }))
if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message"))
const model = next.model ?? {
modelID: msg.info.modelID,
providerID: msg.info.providerID,
}
@@ -123,44 +136,65 @@ export const TaskTool = Tool.define("task", async (ctx) => {
function cancel() {
SessionPrompt.cancel(session.id)
}
ctx.abort.addEventListener("abort", cancel)
using _ = defer(() => ctx.abort.removeEventListener("abort", cancel))
const promptParts = await SessionPrompt.resolvePromptParts(params.prompt)
return yield* Effect.acquireUseRelease(
Effect.sync(() => {
ctx.abort.addEventListener("abort", cancel)
}),
() => Effect.promise(() => SessionPrompt.resolvePromptParts(params.prompt)),
() =>
Effect.sync(() => {
ctx.abort.removeEventListener("abort", cancel)
}),
).pipe(
Effect.flatMap((parts) =>
Effect.promise(() =>
SessionPrompt.prompt({
messageID,
sessionID: session.id,
model: {
modelID: model.modelID,
providerID: model.providerID,
},
agent: next.name,
tools: {
...(hasTodo ? {} : { todowrite: false }),
...(hasTask ? {} : { task: false }),
...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])),
},
parts,
}),
),
),
Effect.map((result) => {
const text = result.parts.findLast((item) => item.type === "text")?.text ?? ""
return {
title: params.description,
metadata: {
sessionId: session.id,
model,
},
output: [
`task_id: ${session.id} (for resuming to continue this task if needed)`,
"",
"<task_result>",
text,
"</task_result>",
].join("\n"),
}
}),
)
})
const result = await SessionPrompt.prompt({
messageID,
sessionID: session.id,
model: {
modelID: model.modelID,
providerID: model.providerID,
},
agent: agent.name,
tools: {
...(hasTodoWritePermission ? {} : { todowrite: false }),
...(hasTaskPermission ? {} : { task: false }),
...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
},
parts: promptParts,
})
const text = result.parts.findLast((x) => x.type === "text")?.text ?? ""
const output = [
`task_id: ${session.id} (for resuming to continue this task if needed)`,
"",
"<task_result>",
text,
"</task_result>",
].join("\n")
return async (ctx) => {
const description = await Effect.runPromise(desc(ctx?.agent))
return {
title: params.description,
metadata: {
sessionId: session.id,
model,
description,
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
return Effect.runPromise(run(params, ctx))
},
output,
}
},
}
})
}
}),
)

View File

@@ -29,7 +29,6 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { Shell } from "../../src/shell/shell"
import { Snapshot } from "../../src/snapshot"
import { TaskTool } from "../../src/tool/task"
import { ToolRegistry } from "../../src/tool/registry"
import { Truncate } from "../../src/tool/truncate"
import { Log } from "../../src/util/log"
@@ -631,7 +630,9 @@ it.live(
Effect.gen(function* () {
const ready = defer<void>()
const aborted = defer<void>()
const init = spyOn(TaskTool, "init").mockImplementation(async () => ({
const registry = yield* ToolRegistry.Service
const init = registry.named.task.init
registry.named.task.init = async () => ({
description: "task",
parameters: z.object({
description: z.string(),
@@ -653,8 +654,8 @@ it.live(
output: "",
}
},
}))
yield* Effect.addFinalizer(() => Effect.sync(() => init.mockRestore()))
})
yield* Effect.addFinalizer(() => Effect.sync(() => void (registry.named.task.init = init)))
const { prompt, chat } = yield* boot()
const msg = yield* user(chat.id, "hello")

View File

@@ -1,12 +1,20 @@
import { afterEach, describe, expect, test } from "bun:test"
import { afterEach, describe, expect } from "bun:test"
import { Cause, Effect, Exit, Layer } from "effect"
import path from "path"
import { ReadTool } from "../../src/tool/read"
import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
import { Permission } from "../../src/permission"
import { Agent } from "../../src/agent/agent"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { AppFileSystem } from "../../src/filesystem"
import { FileTime } from "../../src/file/time"
import { LSP } from "../../src/lsp"
import { Permission } from "../../src/permission"
import { Instance } from "../../src/project/instance"
import { SessionID, MessageID } from "../../src/session/schema"
import { Instruction } from "../../src/session/instruction"
import { ReadTool } from "../../src/tool/read"
import { Tool } from "../../src/tool/tool"
import { Filesystem } from "../../src/util/filesystem"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
@@ -25,173 +33,171 @@ const ctx = {
ask: async () => {},
}
const it = testEffect(
Layer.mergeAll(
Agent.defaultLayer,
AppFileSystem.defaultLayer,
CrossSpawnSpawner.defaultLayer,
FileTime.defaultLayer,
Instruction.defaultLayer,
LSP.defaultLayer,
),
)
const init = Effect.fn("ReadToolTest.init")(function* () {
const info = yield* ReadTool
return yield* Effect.promise(() => info.init())
})
const run = Effect.fn("ReadToolTest.run")(function* (
args: Tool.InferParameters<typeof ReadTool>,
next: Tool.Context = ctx,
) {
const tool = yield* init()
return yield* Effect.promise(() => tool.execute(args, next))
})
const exec = Effect.fn("ReadToolTest.exec")(function* (
dir: string,
args: Tool.InferParameters<typeof ReadTool>,
next: Tool.Context = ctx,
) {
return yield* provideInstance(dir)(run(args, next))
})
const fail = Effect.fn("ReadToolTest.fail")(function* (
dir: string,
args: Tool.InferParameters<typeof ReadTool>,
next: Tool.Context = ctx,
) {
const exit = yield* exec(dir, args, next).pipe(Effect.exit)
if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause)
return err instanceof Error ? err : new Error(String(err))
}
throw new Error("expected read to fail")
})
const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p)
const glob = (p: string) =>
process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
const put = Effect.fn("ReadToolTest.put")(function* (p: string, content: string | Buffer | Uint8Array) {
const fs = yield* AppFileSystem.Service
yield* fs.writeWithDirs(p, content)
})
const load = Effect.fn("ReadToolTest.load")(function* (p: string) {
const fs = yield* AppFileSystem.Service
return yield* fs.readFileString(p)
})
const asks = () => {
const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
return {
items,
next: {
...ctx,
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
items.push(req)
},
},
}
}
describe("tool.read external_directory permission", () => {
test("allows reading absolute path inside project directory", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), "hello world")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "test.txt") }, ctx)
expect(result.output).toContain("hello world")
},
})
})
it.live("allows reading absolute path inside project directory", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "test.txt"), "hello world")
test("allows reading file in subdirectory inside project directory", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "subdir", "test.txt"), "nested content")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "subdir", "test.txt") }, ctx)
expect(result.output).toContain("nested content")
},
})
})
const result = yield* exec(dir, { filePath: path.join(dir, "test.txt") })
expect(result.output).toContain("hello world")
}),
)
test("asks for external_directory permission when reading absolute path outside project", async () => {
await using outerTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "secret.txt"), "secret data")
},
})
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
await read.execute({ filePath: path.join(outerTmp.path, "secret.txt") }, testCtx)
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined()
expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "*")))
},
})
})
it.live("allows reading file in subdirectory inside project directory", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "subdir", "test.txt"), "nested content")
const result = yield* exec(dir, { filePath: path.join(dir, "subdir", "test.txt") })
expect(result.output).toContain("nested content")
}),
)
it.live("asks for external_directory permission when reading absolute path outside project", () =>
Effect.gen(function* () {
const outer = yield* tmpdirScoped()
const dir = yield* tmpdirScoped({ git: true })
yield* put(path.join(outer, "secret.txt"), "secret data")
const { items, next } = asks()
yield* exec(dir, { filePath: path.join(outer, "secret.txt") }, next)
const ext = items.find((item) => item.permission === "external_directory")
expect(ext).toBeDefined()
expect(ext!.patterns).toContain(glob(path.join(outer, "*")))
}),
)
if (process.platform === "win32") {
test("normalizes read permission paths on Windows", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), "hello world")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
const target = path.join(tmp.path, "test.txt")
const alt = target
.replace(/^[A-Za-z]:/, "")
.replaceAll("\\", "/")
.toLowerCase()
await read.execute({ filePath: alt }, testCtx)
const readReq = requests.find((r) => r.permission === "read")
expect(readReq).toBeDefined()
expect(readReq!.patterns).toEqual([full(target)])
},
})
})
it.live("normalizes read permission paths on Windows", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
yield* put(path.join(dir, "test.txt"), "hello world")
const { items, next } = asks()
const target = path.join(dir, "test.txt")
const alt = target
.replace(/^[A-Za-z]:/, "")
.replaceAll("\\", "/")
.toLowerCase()
yield* exec(dir, { filePath: alt }, next)
const read = items.find((item) => item.permission === "read")
expect(read).toBeDefined()
expect(read!.patterns).toEqual([full(target)])
}),
)
}
test("asks for directory-scoped external_directory permission when reading external directory", async () => {
await using outerTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "external", "a.txt"), "a")
},
})
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
await read.execute({ filePath: path.join(outerTmp.path, "external") }, testCtx)
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined()
expect(extDirReq!.patterns).toContain(glob(path.join(outerTmp.path, "external", "*")))
},
})
})
it.live("asks for directory-scoped external_directory permission when reading external directory", () =>
Effect.gen(function* () {
const outer = yield* tmpdirScoped()
const dir = yield* tmpdirScoped({ git: true })
yield* put(path.join(outer, "external", "a.txt"), "a")
test("asks for external_directory permission when reading relative path outside project", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
// This will fail because file doesn't exist, but we can check if permission was asked
await read.execute({ filePath: "../outside.txt" }, testCtx).catch(() => {})
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeDefined()
},
})
})
const { items, next } = asks()
test("does not ask for external_directory permission when reading inside project", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "internal.txt"), "internal content")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
await read.execute({ filePath: path.join(tmp.path, "internal.txt") }, testCtx)
const extDirReq = requests.find((r) => r.permission === "external_directory")
expect(extDirReq).toBeUndefined()
},
})
})
yield* exec(dir, { filePath: path.join(outer, "external") }, next)
const ext = items.find((item) => item.permission === "external_directory")
expect(ext).toBeDefined()
expect(ext!.patterns).toContain(glob(path.join(outer, "external", "*")))
}),
)
it.live("asks for external_directory permission when reading relative path outside project", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const { items, next } = asks()
yield* fail(dir, { filePath: "../outside.txt" }, next)
const ext = items.find((item) => item.permission === "external_directory")
expect(ext).toBeDefined()
}),
)
it.live("does not ask for external_directory permission when reading inside project", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
yield* put(path.join(dir, "internal.txt"), "internal content")
const { items, next } = asks()
yield* exec(dir, { filePath: path.join(dir, "internal.txt") }, next)
const ext = items.find((item) => item.permission === "external_directory")
expect(ext).toBeUndefined()
}),
)
})
describe("tool.read env file permissions", () => {
@@ -205,261 +211,204 @@ describe("tool.read env file permissions", () => {
["environment.ts", false],
]
describe.each(["build", "plan"])("agent=%s", (agentName) => {
test.each(cases)("%s asks=%s", async (filename, shouldAsk) => {
await using tmp = await tmpdir({
init: (dir) => Bun.write(path.join(dir, filename), "content"),
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const agent = await Agent.get(agentName)
let askedForEnv = false
const ctxWithPermissions = {
...ctx,
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
for (const pattern of req.patterns) {
const rule = Permission.evaluate(req.permission, pattern, agent.permission)
if (rule.action === "ask" && req.permission === "read") {
askedForEnv = true
for (const agentName of ["build", "plan"] as const) {
describe(`agent=${agentName}`, () => {
for (const [filename, shouldAsk] of cases) {
it.live(`${filename} asks=${shouldAsk}`, () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, filename), "content")
const asked = yield* provideInstance(dir)(
Effect.gen(function* () {
const agent = yield* Agent.Service
const info = yield* agent.get(agentName)
let asked = false
const next = {
...ctx,
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
for (const pattern of req.patterns) {
const rule = Permission.evaluate(req.permission, pattern, info.permission)
if (rule.action === "ask" && req.permission === "read") {
asked = true
}
if (rule.action === "deny") {
throw new Permission.DeniedError({ ruleset: info.permission })
}
}
},
}
if (rule.action === "deny") {
throw new Permission.DeniedError({ ruleset: agent.permission })
}
}
},
}
const read = await ReadTool.init()
await read.execute({ filePath: path.join(tmp.path, filename) }, ctxWithPermissions)
expect(askedForEnv).toBe(shouldAsk)
},
})
yield* run({ filePath: path.join(dir, filename) }, next)
return asked
}),
)
expect(asked).toBe(shouldAsk)
}),
)
}
})
})
}
})
describe("tool.read truncation", () => {
test("truncates large file by bytes and sets truncated metadata", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const base = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json"))
const target = 60 * 1024
const content = base.length >= target ? base : base.repeat(Math.ceil(target / base.length))
await Filesystem.write(path.join(dir, "large.json"), content)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "large.json") }, ctx)
expect(result.metadata.truncated).toBe(true)
expect(result.output).toContain("Output capped at")
expect(result.output).toContain("Use offset=")
},
})
})
it.live("truncates large file by bytes and sets truncated metadata", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const base = yield* load(path.join(FIXTURES_DIR, "models-api.json"))
const target = 60 * 1024
const content = base.length >= target ? base : base.repeat(Math.ceil(target / base.length))
yield* put(path.join(dir, "large.json"), content)
test("truncates by line count when limit is specified", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
await Bun.write(path.join(dir, "many-lines.txt"), lines)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "many-lines.txt"), limit: 10 }, ctx)
expect(result.metadata.truncated).toBe(true)
expect(result.output).toContain("Showing lines 1-10 of 100")
expect(result.output).toContain("Use offset=11")
expect(result.output).toContain("line0")
expect(result.output).toContain("line9")
expect(result.output).not.toContain("line10")
},
})
})
const result = yield* exec(dir, { filePath: path.join(dir, "large.json") })
expect(result.metadata.truncated).toBe(true)
expect(result.output).toContain("Output capped at")
expect(result.output).toContain("Use offset=")
}),
)
test("does not truncate small file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "small.txt"), "hello world")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "small.txt") }, ctx)
expect(result.metadata.truncated).toBe(false)
expect(result.output).toContain("End of file")
},
})
})
it.live("truncates by line count when limit is specified", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
yield* put(path.join(dir, "many-lines.txt"), lines)
test("respects offset parameter", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join("\n")
await Bun.write(path.join(dir, "offset.txt"), lines)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "offset.txt"), offset: 10, limit: 5 }, ctx)
expect(result.output).toContain("10: line10")
expect(result.output).toContain("14: line14")
expect(result.output).not.toContain("9: line10")
expect(result.output).not.toContain("15: line15")
expect(result.output).toContain("line10")
expect(result.output).toContain("line14")
expect(result.output).not.toContain("line0")
expect(result.output).not.toContain("line15")
},
})
})
const result = yield* exec(dir, { filePath: path.join(dir, "many-lines.txt"), limit: 10 })
expect(result.metadata.truncated).toBe(true)
expect(result.output).toContain("Showing lines 1-10 of 100")
expect(result.output).toContain("Use offset=11")
expect(result.output).toContain("line0")
expect(result.output).toContain("line9")
expect(result.output).not.toContain("line10")
}),
)
test("throws when offset is beyond end of file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const lines = Array.from({ length: 3 }, (_, i) => `line${i + 1}`).join("\n")
await Bun.write(path.join(dir, "short.txt"), lines)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
await expect(
read.execute({ filePath: path.join(tmp.path, "short.txt"), offset: 4, limit: 5 }, ctx),
).rejects.toThrow("Offset 4 is out of range for this file (3 lines)")
},
})
})
it.live("does not truncate small file", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "small.txt"), "hello world")
test("allows reading empty file at default offset", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "empty.txt"), "")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "empty.txt") }, ctx)
expect(result.metadata.truncated).toBe(false)
expect(result.output).toContain("End of file - total 0 lines")
},
})
})
const result = yield* exec(dir, { filePath: path.join(dir, "small.txt") })
expect(result.metadata.truncated).toBe(false)
expect(result.output).toContain("End of file")
}),
)
test("throws when offset > 1 for empty file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "empty.txt"), "")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
await expect(read.execute({ filePath: path.join(tmp.path, "empty.txt"), offset: 2 }, ctx)).rejects.toThrow(
"Offset 2 is out of range for this file (0 lines)",
)
},
})
})
it.live("respects offset parameter", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join("\n")
yield* put(path.join(dir, "offset.txt"), lines)
test("does not mark final directory page as truncated", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Promise.all(
Array.from({ length: 10 }, (_, i) => Bun.write(path.join(dir, "dir", `file-${i + 1}.txt`), `line${i}`)),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "dir"), offset: 6, limit: 5 }, ctx)
expect(result.metadata.truncated).toBe(false)
expect(result.output).not.toContain("Showing 5 of 10 entries")
},
})
})
const result = yield* exec(dir, { filePath: path.join(dir, "offset.txt"), offset: 10, limit: 5 })
expect(result.output).toContain("10: line10")
expect(result.output).toContain("14: line14")
expect(result.output).not.toContain("9: line10")
expect(result.output).not.toContain("15: line15")
expect(result.output).toContain("line10")
expect(result.output).toContain("line14")
expect(result.output).not.toContain("line0")
expect(result.output).not.toContain("line15")
}),
)
test("truncates long lines", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const longLine = "x".repeat(3000)
await Bun.write(path.join(dir, "long-line.txt"), longLine)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "long-line.txt") }, ctx)
expect(result.output).toContain("(line truncated to 2000 chars)")
expect(result.output.length).toBeLessThan(3000)
},
})
})
it.live("throws when offset is beyond end of file", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const lines = Array.from({ length: 3 }, (_, i) => `line${i + 1}`).join("\n")
yield* put(path.join(dir, "short.txt"), lines)
test("image files set truncated to false", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
// 1x1 red PNG
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
"base64",
)
await Bun.write(path.join(dir, "image.png"), png)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "image.png") }, ctx)
expect(result.metadata.truncated).toBe(false)
expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1)
expect(result.attachments?.[0]).not.toHaveProperty("id")
expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
expect(result.attachments?.[0]).not.toHaveProperty("messageID")
},
})
})
const err = yield* fail(dir, { filePath: path.join(dir, "short.txt"), offset: 4, limit: 5 })
expect(err.message).toContain("Offset 4 is out of range for this file (3 lines)")
}),
)
test("large image files are properly attached without error", async () => {
await Instance.provide({
directory: FIXTURES_DIR,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(FIXTURES_DIR, "large-image.png") }, ctx)
expect(result.metadata.truncated).toBe(false)
expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1)
expect(result.attachments?.[0].type).toBe("file")
expect(result.attachments?.[0]).not.toHaveProperty("id")
expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
expect(result.attachments?.[0]).not.toHaveProperty("messageID")
},
})
})
it.live("allows reading empty file at default offset", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "empty.txt"), "")
test(".fbs files (FlatBuffers schema) are read as text, not images", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
// FlatBuffers schema content
const fbsContent = `namespace MyGame;
const result = yield* exec(dir, { filePath: path.join(dir, "empty.txt") })
expect(result.metadata.truncated).toBe(false)
expect(result.output).toContain("End of file - total 0 lines")
}),
)
it.live("throws when offset > 1 for empty file", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "empty.txt"), "")
const err = yield* fail(dir, { filePath: path.join(dir, "empty.txt"), offset: 2 })
expect(err.message).toContain("Offset 2 is out of range for this file (0 lines)")
}),
)
it.live("does not mark final directory page as truncated", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* Effect.forEach(
Array.from({ length: 10 }, (_, i) => i),
(i) => put(path.join(dir, "dir", `file-${i + 1}.txt`), `line${i}`),
{
concurrency: "unbounded",
},
)
const result = yield* exec(dir, { filePath: path.join(dir, "dir"), offset: 6, limit: 5 })
expect(result.metadata.truncated).toBe(false)
expect(result.output).not.toContain("Showing 5 of 10 entries")
}),
)
it.live("truncates long lines", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "long-line.txt"), "x".repeat(3000))
const result = yield* exec(dir, { filePath: path.join(dir, "long-line.txt") })
expect(result.output).toContain("(line truncated to 2000 chars)")
expect(result.output.length).toBeLessThan(3000)
}),
)
it.live("image files set truncated to false", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
"base64",
)
yield* put(path.join(dir, "image.png"), png)
const result = yield* exec(dir, { filePath: path.join(dir, "image.png") })
expect(result.metadata.truncated).toBe(false)
expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1)
expect(result.attachments?.[0]).not.toHaveProperty("id")
expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
expect(result.attachments?.[0]).not.toHaveProperty("messageID")
}),
)
it.live("large image files are properly attached without error", () =>
Effect.gen(function* () {
const result = yield* exec(FIXTURES_DIR, { filePath: path.join(FIXTURES_DIR, "large-image.png") })
expect(result.metadata.truncated).toBe(false)
expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1)
expect(result.attachments?.[0].type).toBe("file")
expect(result.attachments?.[0]).not.toHaveProperty("id")
expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
expect(result.attachments?.[0]).not.toHaveProperty("messageID")
}),
)
it.live(".fbs files (FlatBuffers schema) are read as text, not images", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const fbs = `namespace MyGame;
table Monster {
pos:Vec3;
@@ -468,79 +417,52 @@ table Monster {
}
root_type Monster;`
await Bun.write(path.join(dir, "schema.fbs"), fbsContent)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "schema.fbs") }, ctx)
// Should be read as text, not as image
expect(result.attachments).toBeUndefined()
expect(result.output).toContain("namespace MyGame")
expect(result.output).toContain("table Monster")
},
})
})
yield* put(path.join(dir, "schema.fbs"), fbs)
const result = yield* exec(dir, { filePath: path.join(dir, "schema.fbs") })
expect(result.attachments).toBeUndefined()
expect(result.output).toContain("namespace MyGame")
expect(result.output).toContain("table Monster")
}),
)
})
describe("tool.read loaded instructions", () => {
test("loads AGENTS.md from parent directory and includes in metadata", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Test Instructions\nDo something special.")
await Bun.write(path.join(dir, "subdir", "nested", "test.txt"), "test content")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "subdir", "nested", "test.txt") }, ctx)
expect(result.output).toContain("test content")
expect(result.output).toContain("system-reminder")
expect(result.output).toContain("Test Instructions")
expect(result.metadata.loaded).toBeDefined()
expect(result.metadata.loaded).toContain(path.join(tmp.path, "subdir", "AGENTS.md"))
},
})
})
it.live("loads AGENTS.md from parent directory and includes in metadata", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "subdir", "AGENTS.md"), "# Test Instructions\nDo something special.")
yield* put(path.join(dir, "subdir", "nested", "test.txt"), "test content")
const result = yield* exec(dir, { filePath: path.join(dir, "subdir", "nested", "test.txt") })
expect(result.output).toContain("test content")
expect(result.output).toContain("system-reminder")
expect(result.output).toContain("Test Instructions")
expect(result.metadata.loaded).toBeDefined()
expect(result.metadata.loaded).toContain(path.join(dir, "subdir", "AGENTS.md"))
}),
)
})
describe("tool.read binary detection", () => {
test("rejects text extension files with null bytes", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const bytes = Buffer.from([0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x77, 0x6f, 0x72, 0x6c, 0x64])
await Bun.write(path.join(dir, "null-byte.txt"), bytes)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
await expect(read.execute({ filePath: path.join(tmp.path, "null-byte.txt") }, ctx)).rejects.toThrow(
"Cannot read binary file",
)
},
})
})
it.live("rejects text extension files with null bytes", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const bytes = Buffer.from([0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x77, 0x6f, 0x72, 0x6c, 0x64])
yield* put(path.join(dir, "null-byte.txt"), bytes)
test("rejects known binary extensions", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "module.wasm"), "not really wasm")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
await expect(read.execute({ filePath: path.join(tmp.path, "module.wasm") }, ctx)).rejects.toThrow(
"Cannot read binary file",
)
},
})
})
const err = yield* fail(dir, { filePath: path.join(dir, "null-byte.txt") })
expect(err.message).toContain("Cannot read binary file")
}),
)
it.live("rejects known binary extensions", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "module.wasm"), "not really wasm")
const err = yield* fail(dir, { filePath: path.join(dir, "module.wasm") })
expect(err.message).toContain("Cannot read binary file")
}),
)
})

View File

@@ -1,49 +1,56 @@
import { afterEach, describe, expect, test } from "bun:test"
import { afterEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Agent } from "../../src/agent/agent"
import { Config } from "../../src/config/config"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Instance } from "../../src/project/instance"
import { TaskTool } from "../../src/tool/task"
import { tmpdir } from "../fixture/fixture"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => {
await Instance.disposeAll()
})
const it = testEffect(Layer.mergeAll(Agent.defaultLayer, Config.defaultLayer, CrossSpawnSpawner.defaultLayer))
describe("tool.task", () => {
test("description sorts subagents by name and is stable across calls", async () => {
await using tmp = await tmpdir({
config: {
agent: {
zebra: {
description: "Zebra agent",
mode: "subagent",
},
alpha: {
description: "Alpha agent",
mode: "subagent",
it.live("description sorts subagents by name and is stable across calls", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const build = yield* agent.get("build")
const tool = yield* TaskTool
const first = yield* Effect.promise(() => tool.init({ agent: build }))
const second = yield* Effect.promise(() => tool.init({ agent: build }))
expect(first.description).toBe(second.description)
const alpha = first.description.indexOf("- alpha: Alpha agent")
const explore = first.description.indexOf("- explore:")
const general = first.description.indexOf("- general:")
const zebra = first.description.indexOf("- zebra: Zebra agent")
expect(alpha).toBeGreaterThan(-1)
expect(explore).toBeGreaterThan(alpha)
expect(general).toBeGreaterThan(explore)
expect(zebra).toBeGreaterThan(general)
}),
{
config: {
agent: {
zebra: {
description: "Zebra agent",
mode: "subagent",
},
alpha: {
description: "Alpha agent",
mode: "subagent",
},
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const first = await TaskTool.init({ agent: build })
const second = await TaskTool.init({ agent: build })
expect(first.description).toBe(second.description)
const alpha = first.description.indexOf("- alpha: Alpha agent")
const explore = first.description.indexOf("- explore:")
const general = first.description.indexOf("- general:")
const zebra = first.description.indexOf("- zebra: Zebra agent")
expect(alpha).toBeGreaterThan(-1)
expect(explore).toBeGreaterThan(alpha)
expect(general).toBeGreaterThan(explore)
expect(zebra).toBeGreaterThan(general)
},
})
})
),
)
})

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.3.15",
"version": "1.3.13",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.3.15",
"version": "1.3.13",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.3.15",
"version": "1.3.13",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.3.15",
"version": "1.3.13",
"type": "module",
"license": "MIT",
"exports": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.3.15",
"version": "1.3.13",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.3.15",
"version": "1.3.13",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",

View File

@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.3.15",
"version": "1.3.13",
"publisher": "sst-dev",
"repository": {
"type": "git",