summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-03-17 21:59:54 -0400
committerGitHub <[email protected]>2026-03-17 21:59:54 -0400
commit5dfe86dcb17632cd717288500120a61843d1b071 (patch)
tree33dbb0597681efc39e6dc28c3d9f63160ac91dc1
parent4b4dd2b882380919609340c404bdcb221cf457a3 (diff)
downloadopencode-5dfe86dcb17632cd717288500120a61843d1b071.tar.gz
opencode-5dfe86dcb17632cd717288500120a61843d1b071.zip
refactor(truncation): effectify TruncateService, delete Scheduler (#17957)
-rw-r--r--packages/opencode/src/agent/agent.ts4
-rw-r--r--packages/opencode/src/cli/cmd/debug/agent.ts2
-rw-r--r--packages/opencode/src/cli/cmd/run.ts2
-rw-r--r--packages/opencode/src/effect/instances.ts10
-rw-r--r--packages/opencode/src/effect/runtime.ts7
-rw-r--r--packages/opencode/src/permission/index.ts (renamed from packages/opencode/src/permission/next.ts)12
-rw-r--r--packages/opencode/src/permission/service.ts213
-rw-r--r--packages/opencode/src/project/bootstrap.ts4
-rw-r--r--packages/opencode/src/scheduler/index.ts61
-rw-r--r--packages/opencode/src/server/routes/permission.ts2
-rw-r--r--packages/opencode/src/server/routes/session.ts2
-rw-r--r--packages/opencode/src/session/index.ts2
-rw-r--r--packages/opencode/src/session/llm.ts2
-rw-r--r--packages/opencode/src/session/processor.ts2
-rw-r--r--packages/opencode/src/session/prompt.ts4
-rw-r--r--packages/opencode/src/session/session.sql.ts2
-rw-r--r--packages/opencode/src/session/system.ts2
-rw-r--r--packages/opencode/src/skill/skill.ts2
-rw-r--r--packages/opencode/src/tool/bash.ts2
-rw-r--r--packages/opencode/src/tool/registry.ts2
-rw-r--r--packages/opencode/src/tool/task.ts2
-rw-r--r--packages/opencode/src/tool/tool.ts4
-rw-r--r--packages/opencode/src/tool/truncate-effect.ts140
-rw-r--r--packages/opencode/src/tool/truncate.ts19
-rw-r--r--packages/opencode/src/tool/truncation-dir.ts4
-rw-r--r--packages/opencode/src/tool/truncation.ts108
-rw-r--r--packages/opencode/test/account/repo.test.ts38
-rw-r--r--packages/opencode/test/account/service.test.ts32
-rw-r--r--packages/opencode/test/agent/agent.test.ts10
-rw-r--r--packages/opencode/test/fixture/effect.ts7
-rw-r--r--packages/opencode/test/lib/effect.ts37
-rw-r--r--packages/opencode/test/lib/filesystem.ts10
-rw-r--r--packages/opencode/test/permission-task.test.ts2
-rw-r--r--packages/opencode/test/permission/next.test.ts4
-rw-r--r--packages/opencode/test/scheduler.test.ts73
-rw-r--r--packages/opencode/test/tool/bash.test.ts4
-rw-r--r--packages/opencode/test/tool/external-directory.test.ts2
-rw-r--r--packages/opencode/test/tool/read.test.ts2
-rw-r--r--packages/opencode/test/tool/skill.test.ts2
-rw-r--r--packages/opencode/test/tool/truncation.test.ts50
40 files changed, 406 insertions, 483 deletions
diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts
index b247bb7fa..b2dae0402 100644
--- a/packages/opencode/src/agent/agent.ts
+++ b/packages/opencode/src/agent/agent.ts
@@ -5,7 +5,7 @@ import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai"
import { SystemPrompt } from "../session/system"
import { Instance } from "../project/instance"
-import { Truncate } from "../tool/truncation"
+import { Truncate } from "../tool/truncate"
import { Auth } from "../auth"
import { ProviderTransform } from "../provider/transform"
@@ -14,7 +14,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt"
import PROMPT_EXPLORE from "./prompt/explore.txt"
import PROMPT_SUMMARY from "./prompt/summary.txt"
import PROMPT_TITLE from "./prompt/title.txt"
-import { PermissionNext } from "@/permission/next"
+import { PermissionNext } from "@/permission"
import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@/global"
import path from "path"
diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts
index ef075d732..f33dcc558 100644
--- a/packages/opencode/src/cli/cmd/debug/agent.ts
+++ b/packages/opencode/src/cli/cmd/debug/agent.ts
@@ -7,7 +7,7 @@ import type { MessageV2 } from "../../../session/message-v2"
import { MessageID, PartID } from "../../../session/schema"
import { ToolRegistry } from "../../../tool/registry"
import { Instance } from "../../../project/instance"
-import { PermissionNext } from "../../../permission/next"
+import { PermissionNext } from "../../../permission"
import { iife } from "../../../util/iife"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts
index f92d3305b..85b5689da 100644
--- a/packages/opencode/src/cli/cmd/run.ts
+++ b/packages/opencode/src/cli/cmd/run.ts
@@ -11,7 +11,7 @@ import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart
import { Server } from "../../server/server"
import { Provider } from "../../provider/provider"
import { Agent } from "../../agent/agent"
-import { PermissionNext } from "../../permission/next"
+import { PermissionNext } from "../../permission"
import { Tool } from "../../tool/tool"
import { GlobTool } from "../../tool/glob"
import { GrepTool } from "../../tool/grep"
diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts
index 16186f729..3a1fb0cdf 100644
--- a/packages/opencode/src/effect/instances.ts
+++ b/packages/opencode/src/effect/instances.ts
@@ -3,7 +3,7 @@ import { FileService } from "@/file"
import { FileTimeService } from "@/file/time"
import { FileWatcherService } from "@/file/watcher"
import { FormatService } from "@/format"
-import { PermissionService } from "@/permission/service"
+import { PermissionEffect } from "@/permission/service"
import { Instance } from "@/project/instance"
import { VcsService } from "@/project/vcs"
import { ProviderAuthService } from "@/provider/auth-service"
@@ -17,7 +17,7 @@ export { InstanceContext } from "./instance-context"
export type InstanceServices =
| QuestionService
- | PermissionService
+ | PermissionEffect.Service
| ProviderAuthService
| FileWatcherService
| VcsService
@@ -37,7 +37,7 @@ function lookup(_key: string) {
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
return Layer.mergeAll(
Layer.fresh(QuestionService.layer),
- Layer.fresh(PermissionService.layer),
+ Layer.fresh(PermissionEffect.layer),
Layer.fresh(ProviderAuthService.layer),
Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie),
Layer.fresh(VcsService.layer),
@@ -67,8 +67,4 @@ export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<s
static get(directory: string): Layer.Layer<InstanceServices, never, Instances> {
return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory))))
}
-
- static invalidate(directory: string): Effect.Effect<void, never, Instances> {
- return Instances.use((map) => map.invalidate(directory))
- }
}
diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts
index cf7d73f77..a55956bfd 100644
--- a/packages/opencode/src/effect/runtime.ts
+++ b/packages/opencode/src/effect/runtime.ts
@@ -3,10 +3,15 @@ import { AccountService } from "@/account/service"
import { AuthService } from "@/auth/service"
import { Instances } from "@/effect/instances"
import type { InstanceServices } from "@/effect/instances"
+import { TruncateEffect } from "@/tool/truncate-effect"
import { Instance } from "@/project/instance"
export const runtime = ManagedRuntime.make(
- Layer.mergeAll(AccountService.defaultLayer, Instances.layer).pipe(Layer.provideMerge(AuthService.defaultLayer)),
+ Layer.mergeAll(
+ AccountService.defaultLayer, //
+ TruncateEffect.defaultLayer,
+ Instances.layer,
+ ).pipe(Layer.provideMerge(AuthService.defaultLayer)),
)
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/index.ts
index a6db55222..e7eb0eea6 100644
--- a/packages/opencode/src/permission/next.ts
+++ b/packages/opencode/src/permission/index.ts
@@ -3,7 +3,7 @@ import { Config } from "@/config/config"
import { fn } from "@/util/fn"
import { Wildcard } from "@/util/wildcard"
import os from "os"
-import * as S from "./service"
+import { PermissionEffect as S } from "./service"
export namespace PermissionNext {
function expand(pattern: string): string {
@@ -26,7 +26,7 @@ export namespace PermissionNext {
export type Reply = S.Reply
export const Approval = S.Approval
export const Event = S.Event
- export const Service = S.PermissionService
+ export const Service = S.Service
export const RejectedError = S.RejectedError
export const CorrectedError = S.CorrectedError
export const DeniedError = S.DeniedError
@@ -53,16 +53,14 @@ export namespace PermissionNext {
return rulesets.flat()
}
- export const ask = fn(S.AskInput, async (input) =>
- runPromiseInstance(S.PermissionService.use((service) => service.ask(input))),
- )
+ export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((service) => service.ask(input))))
export const reply = fn(S.ReplyInput, async (input) =>
- runPromiseInstance(S.PermissionService.use((service) => service.reply(input))),
+ runPromiseInstance(S.Service.use((service) => service.reply(input))),
)
export async function list() {
- return runPromiseInstance(S.PermissionService.use((service) => service.list()))
+ return runPromiseInstance(S.Service.use((service) => service.list()))
}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
diff --git a/packages/opencode/src/permission/service.ts b/packages/opencode/src/permission/service.ts
index f20b19acf..4335aa4cd 100644
--- a/packages/opencode/src/permission/service.ts
+++ b/packages/opencode/src/permission/service.ts
@@ -11,121 +11,128 @@ import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import z from "zod"
import { PermissionID } from "./schema"
-const log = Log.create({ service: "permission" })
-
-export const Action = z.enum(["allow", "deny", "ask"]).meta({
- ref: "PermissionAction",
-})
-export type Action = z.infer<typeof Action>
-
-export const Rule = z
- .object({
- permission: z.string(),
- pattern: z.string(),
- action: Action,
- })
- .meta({
- ref: "PermissionRule",
- })
-export type Rule = z.infer<typeof Rule>
-
-export const Ruleset = Rule.array().meta({
- ref: "PermissionRuleset",
-})
-export type Ruleset = z.infer<typeof Ruleset>
-
-export const Request = z
- .object({
- id: PermissionID.zod,
- sessionID: SessionID.zod,
- permission: z.string(),
- patterns: z.string().array(),
- metadata: z.record(z.string(), z.any()),
- always: z.string().array(),
- tool: z
- .object({
- messageID: MessageID.zod,
- callID: z.string(),
- })
- .optional(),
+export namespace PermissionEffect {
+ const log = Log.create({ service: "permission" })
+
+ export const Action = z.enum(["allow", "deny", "ask"]).meta({
+ ref: "PermissionAction",
})
- .meta({
- ref: "PermissionRequest",
+ export type Action = z.infer<typeof Action>
+
+ export const Rule = z
+ .object({
+ permission: z.string(),
+ pattern: z.string(),
+ action: Action,
+ })
+ .meta({
+ ref: "PermissionRule",
+ })
+ export type Rule = z.infer<typeof Rule>
+
+ export const Ruleset = Rule.array().meta({
+ ref: "PermissionRuleset",
})
-export type Request = z.infer<typeof Request>
-
-export const Reply = z.enum(["once", "always", "reject"])
-export type Reply = z.infer<typeof Reply>
+ export type Ruleset = z.infer<typeof Ruleset>
-export const Approval = z.object({
- projectID: ProjectID.zod,
- patterns: z.string().array(),
-})
-
-export const Event = {
- Asked: BusEvent.define("permission.asked", Request),
- Replied: BusEvent.define(
- "permission.replied",
- z.object({
+ export const Request = z
+ .object({
+ id: PermissionID.zod,
sessionID: SessionID.zod,
- requestID: PermissionID.zod,
- reply: Reply,
- }),
- ),
-}
+ permission: z.string(),
+ patterns: z.string().array(),
+ metadata: z.record(z.string(), z.any()),
+ always: z.string().array(),
+ tool: z
+ .object({
+ messageID: MessageID.zod,
+ callID: z.string(),
+ })
+ .optional(),
+ })
+ .meta({
+ ref: "PermissionRequest",
+ })
+ export type Request = z.infer<typeof Request>
+
+ export const Reply = z.enum(["once", "always", "reject"])
+ export type Reply = z.infer<typeof Reply>
+
+ export const Approval = z.object({
+ projectID: ProjectID.zod,
+ patterns: z.string().array(),
+ })
-export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
- override get message() {
- return "The user rejected permission to use this specific tool call."
+ export const Event = {
+ Asked: BusEvent.define("permission.asked", Request),
+ Replied: BusEvent.define(
+ "permission.replied",
+ z.object({
+ sessionID: SessionID.zod,
+ requestID: PermissionID.zod,
+ reply: Reply,
+ }),
+ ),
}
-}
-export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
- feedback: Schema.String,
-}) {
- override get message() {
- return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
+ export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
+ override get message() {
+ return "The user rejected permission to use this specific tool call."
+ }
}
-}
-export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
- ruleset: Schema.Any,
-}) {
- override get message() {
- return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
+ export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
+ feedback: Schema.String,
+ }) {
+ override get message() {
+ return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
+ }
}
-}
-export type PermissionError = DeniedError | RejectedError | CorrectedError
+ export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
+ ruleset: Schema.Any,
+ }) {
+ override get message() {
+ return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
+ }
+ }
-interface PendingEntry {
- info: Request
- deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
-}
+ export type Error = DeniedError | RejectedError | CorrectedError
-export const AskInput = Request.partial({ id: true }).extend({
- ruleset: Ruleset,
-})
+ export const AskInput = Request.partial({ id: true }).extend({
+ ruleset: Ruleset,
+ })
-export const ReplyInput = z.object({
- requestID: PermissionID.zod,
- reply: Reply,
- message: z.string().optional(),
-})
+ export const ReplyInput = z.object({
+ requestID: PermissionID.zod,
+ reply: Reply,
+ message: z.string().optional(),
+ })
-export declare namespace PermissionService {
export interface Api {
- readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, PermissionError>
+ readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]>
}
-}
-export class PermissionService extends ServiceMap.Service<PermissionService, PermissionService.Api>()(
- "@opencode/PermissionNext",
-) {
- static readonly layer = Layer.effect(
- PermissionService,
+ interface PendingEntry {
+ info: Request
+ deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
+ }
+
+ export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
+ const rules = rulesets.flat()
+ log.info("evaluate", { permission, pattern, ruleset: rules })
+ const match = rules.findLast(
+ (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
+ )
+ return match ?? { action: "ask", permission, pattern: "*" }
+ }
+
+ export class Service extends ServiceMap.Service<Service, Api>()("@opencode/PermissionNext") {}
+
+ export const layer = Layer.effect(
+ Service,
Effect.gen(function* () {
const { project } = yield* InstanceContext
const row = Database.use((db) =>
@@ -225,27 +232,13 @@ export class PermissionService extends ServiceMap.Service<PermissionService, Per
})
yield* Deferred.succeed(item.deferred, undefined)
}
-
- // TODO: we don't save the permission ruleset to disk yet until there's
- // UI to manage it
- // db().insert(PermissionTable).values({ projectID: Instance.project.id, data: s.approved })
- // .onConflictDoUpdate({ target: PermissionTable.projectID, set: { data: s.approved } }).run()
})
const list = Effect.fn("PermissionService.list")(function* () {
return Array.from(pending.values(), (item) => item.info)
})
- return PermissionService.of({ ask, reply, list })
+ return Service.of({ ask, reply, list })
}),
)
}
-
-export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
- const merged = rulesets.flat()
- log.info("evaluate", { permission, pattern, ruleset: merged })
- const match = merged.findLast(
- (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
- )
- return match ?? { action: "ask", permission, pattern: "*" }
-}
diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts
index 00ced358d..40a4ce9cc 100644
--- a/packages/opencode/src/project/bootstrap.ts
+++ b/packages/opencode/src/project/bootstrap.ts
@@ -10,8 +10,6 @@ import { Instance } from "./instance"
import { VcsService } from "./vcs"
import { Log } from "@/util/log"
import { ShareNext } from "@/share/share-next"
-import { Snapshot } from "../snapshot"
-import { Truncate } from "../tool/truncation"
import { runPromiseInstance } from "@/effect/runtime"
export async function InstanceBootstrap() {
@@ -23,8 +21,6 @@ export async function InstanceBootstrap() {
await runPromiseInstance(FileWatcherService.use((service) => service.init()))
File.init()
await runPromiseInstance(VcsService.use((s) => s.init()))
- Snapshot.init()
- Truncate.init()
Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {
diff --git a/packages/opencode/src/scheduler/index.ts b/packages/opencode/src/scheduler/index.ts
deleted file mode 100644
index cfafa7b9c..000000000
--- a/packages/opencode/src/scheduler/index.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { Instance } from "../project/instance"
-import { Log } from "../util/log"
-
-export namespace Scheduler {
- const log = Log.create({ service: "scheduler" })
-
- export type Task = {
- id: string
- interval: number
- run: () => Promise<void>
- scope?: "instance" | "global"
- }
-
- type Timer = ReturnType<typeof setInterval>
- type Entry = {
- tasks: Map<string, Task>
- timers: Map<string, Timer>
- }
-
- const create = (): Entry => {
- const tasks = new Map<string, Task>()
- const timers = new Map<string, Timer>()
- return { tasks, timers }
- }
-
- const shared = create()
-
- const state = Instance.state(
- () => create(),
- async (entry) => {
- for (const timer of entry.timers.values()) {
- clearInterval(timer)
- }
- entry.tasks.clear()
- entry.timers.clear()
- },
- )
-
- export function register(task: Task) {
- const scope = task.scope ?? "instance"
- const entry = scope === "global" ? shared : state()
- const current = entry.timers.get(task.id)
- if (current && scope === "global") return
- if (current) clearInterval(current)
-
- entry.tasks.set(task.id, task)
- void run(task)
- const timer = setInterval(() => {
- void run(task)
- }, task.interval)
- timer.unref()
- entry.timers.set(task.id, timer)
- }
-
- async function run(task: Task) {
- log.info("run", { id: task.id })
- await task.run().catch((error) => {
- log.error("run failed", { id: task.id, error })
- })
- }
-}
diff --git a/packages/opencode/src/server/routes/permission.ts b/packages/opencode/src/server/routes/permission.ts
index 6d86703c6..cc6c26d43 100644
--- a/packages/opencode/src/server/routes/permission.ts
+++ b/packages/opencode/src/server/routes/permission.ts
@@ -1,7 +1,7 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
-import { PermissionNext } from "@/permission/next"
+import { PermissionNext } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts
index b8fafd336..613c8b05c 100644
--- a/packages/opencode/src/server/routes/session.ts
+++ b/packages/opencode/src/server/routes/session.ts
@@ -14,7 +14,7 @@ import { Todo } from "../../session/todo"
import { Agent } from "../../agent/agent"
import { Snapshot } from "@/snapshot"
import { Log } from "../../util/log"
-import { PermissionNext } from "@/permission/next"
+import { PermissionNext } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { errors } from "../error"
diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts
index 0879fe87f..01fd214e0 100644
--- a/packages/opencode/src/session/index.ts
+++ b/packages/opencode/src/session/index.ts
@@ -28,7 +28,7 @@ import { SessionID, MessageID, PartID } from "./schema"
import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
-import { PermissionNext } from "@/permission/next"
+import { PermissionNext } from "@/permission"
import { Global } from "@/global"
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
import { iife } from "@/util/iife"
diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts
index 88841a30a..bcf1b3e6a 100644
--- a/packages/opencode/src/session/llm.ts
+++ b/packages/opencode/src/session/llm.ts
@@ -20,7 +20,7 @@ import type { MessageV2 } from "./message-v2"
import { Plugin } from "@/plugin"
import { SystemPrompt } from "./system"
import { Flag } from "@/flag/flag"
-import { PermissionNext } from "@/permission/next"
+import { PermissionNext } from "@/permission"
import { Auth } from "@/auth"
export namespace LLM {
diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts
index 38dac41b0..158b83865 100644
--- a/packages/opencode/src/session/processor.ts
+++ b/packages/opencode/src/session/processor.ts
@@ -12,7 +12,7 @@ import type { Provider } from "@/provider/provider"
import { LLM } from "./llm"
import { Config } from "@/config/config"
import { SessionCompaction } from "./compaction"
-import { PermissionNext } from "@/permission/next"
+import { PermissionNext } from "@/permission"
import { Question } from "@/question"
import { PartID } from "./schema"
import type { SessionID, MessageID } from "./schema"
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 27a379daa..36162656a 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -41,12 +41,12 @@ import { fn } from "@/util/fn"
import { SessionProcessor } from "./processor"
import { TaskTool } from "@/tool/task"
import { Tool } from "@/tool/tool"
-import { PermissionNext } from "@/permission/next"
+import { PermissionNext } from "@/permission"
import { SessionStatus } from "./status"
import { LLM } from "./llm"
import { iife } from "@/util/iife"
import { Shell } from "@/shell/shell"
-import { Truncate } from "@/tool/truncation"
+import { Truncate } from "@/tool/truncate"
import { decodeDataUrl } from "@/util/data-url"
// @ts-ignore
diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts
index b3229edd1..ea1c4dafb 100644
--- a/packages/opencode/src/session/session.sql.ts
+++ b/packages/opencode/src/session/session.sql.ts
@@ -2,7 +2,7 @@ import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlit
import { ProjectTable } from "../project/project.sql"
import type { MessageV2 } from "./message-v2"
import type { Snapshot } from "../snapshot"
-import type { PermissionNext } from "../permission/next"
+import type { PermissionNext } from "../permission"
import type { ProjectID } from "../project/schema"
import type { SessionID, MessageID, PartID } from "./schema"
import type { WorkspaceID } from "../control-plane/schema"
diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts
index a4c4684ff..d74f58bef 100644
--- a/packages/opencode/src/session/system.ts
+++ b/packages/opencode/src/session/system.ts
@@ -11,7 +11,7 @@ import PROMPT_CODEX from "./prompt/codex_header.txt"
import PROMPT_TRINITY from "./prompt/trinity.txt"
import type { Provider } from "@/provider/provider"
import type { Agent } from "@/agent/agent"
-import { PermissionNext } from "@/permission/next"
+import { PermissionNext } from "@/permission"
import { Skill } from "@/skill"
export namespace SystemPrompt {
diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts
index 3a544d90a..79be9f779 100644
--- a/packages/opencode/src/skill/skill.ts
+++ b/packages/opencode/src/skill/skill.ts
@@ -14,7 +14,7 @@ import { DiscoveryService } from "./discovery"
import { Glob } from "../util/glob"
import { pathToFileURL } from "url"
import type { Agent } from "@/agent/agent"
-import { PermissionNext } from "@/permission/next"
+import { PermissionNext } from "@/permission"
import { InstanceContext } from "@/effect/instance-context"
import { Effect, Layer, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts
index 109a66536..50ae4abac 100644
--- a/packages/opencode/src/tool/bash.ts
+++ b/packages/opencode/src/tool/bash.ts
@@ -15,7 +15,7 @@ import { Flag } from "@/flag/flag.ts"
import { Shell } from "@/shell/shell"
import { BashArity } from "@/permission/arity"
-import { Truncate } from "./truncation"
+import { Truncate } from "./truncate"
import { Plugin } from "@/plugin"
const MAX_METADATA_LENGTH = 30_000
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index 3ea242a29..da9a89790 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -26,7 +26,7 @@ import { CodeSearchTool } from "./codesearch"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { LspTool } from "./lsp"
-import { Truncate } from "./truncation"
+import { Truncate } from "./truncate"
import { ApplyPatchTool } from "./apply_patch"
import { Glob } from "../util/glob"
diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts
index 68e44eb97..14ecea107 100644
--- a/packages/opencode/src/tool/task.ts
+++ b/packages/opencode/src/tool/task.ts
@@ -10,7 +10,7 @@ import { SessionPrompt } from "../session/prompt"
import { iife } from "@/util/iife"
import { defer } from "@/util/defer"
import { Config } from "../config/config"
-import { PermissionNext } from "@/permission/next"
+import { PermissionNext } from "@/permission"
const parameters = z.object({
description: z.string().describe("A short (3-5 words) description of the task"),
diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts
index 8cc7b57d8..d29af86f8 100644
--- a/packages/opencode/src/tool/tool.ts
+++ b/packages/opencode/src/tool/tool.ts
@@ -1,9 +1,9 @@
import z from "zod"
import type { MessageV2 } from "../session/message-v2"
import type { Agent } from "../agent/agent"
-import type { PermissionNext } from "../permission/next"
+import type { PermissionNext } from "../permission"
import type { SessionID, MessageID } from "../session/schema"
-import { Truncate } from "./truncation"
+import { Truncate } from "./truncate"
export namespace Tool {
interface Metadata {
diff --git a/packages/opencode/src/tool/truncate-effect.ts b/packages/opencode/src/tool/truncate-effect.ts
new file mode 100644
index 000000000..4d0ed8168
--- /dev/null
+++ b/packages/opencode/src/tool/truncate-effect.ts
@@ -0,0 +1,140 @@
+import { NodeFileSystem, NodePath } from "@effect/platform-node"
+import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap } from "effect"
+import path from "path"
+import type { Agent } from "../agent/agent"
+import { PermissionEffect } from "../permission/service"
+import { Identifier } from "../id/id"
+import { Log } from "../util/log"
+import { ToolID } from "./schema"
+import { TRUNCATION_DIR } from "./truncation-dir"
+
+export namespace TruncateEffect {
+ const log = Log.create({ service: "truncation" })
+ const RETENTION = Duration.days(7)
+
+ export const MAX_LINES = 2000
+ export const MAX_BYTES = 50 * 1024
+ export const DIR = TRUNCATION_DIR
+ export const GLOB = path.join(TRUNCATION_DIR, "*")
+
+ export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
+
+ export interface Options {
+ maxLines?: number
+ maxBytes?: number
+ direction?: "head" | "tail"
+ }
+
+ function hasTaskTool(agent?: Agent.Info) {
+ if (!agent?.permission) return false
+ return PermissionEffect.evaluate("task", "*", agent.permission).action !== "deny"
+ }
+
+ export interface Api {
+ readonly cleanup: () => Effect.Effect<void>
+ /**
+ * Returns output unchanged when it fits within the limits, otherwise writes the full text
+ * to the truncation directory and returns a preview plus a hint to inspect the saved file.
+ */
+ readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
+ }
+
+ export class Service extends ServiceMap.Service<Service, Api>()("@opencode/Truncate") {}
+
+ export const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem
+
+ const cleanup = Effect.fn("TruncateEffect.cleanup")(function* () {
+ const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
+ const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
+ Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
+ Effect.catch(() => Effect.succeed([])),
+ )
+ for (const entry of entries) {
+ if (Identifier.timestamp(entry) >= cutoff) continue
+ yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
+ }
+ })
+
+ const output = Effect.fn("TruncateEffect.output")(function* (
+ text: string,
+ options: Options = {},
+ agent?: Agent.Info,
+ ) {
+ const maxLines = options.maxLines ?? MAX_LINES
+ const maxBytes = options.maxBytes ?? MAX_BYTES
+ const direction = options.direction ?? "head"
+ const lines = text.split("\n")
+ const totalBytes = Buffer.byteLength(text, "utf-8")
+
+ if (lines.length <= maxLines && totalBytes <= maxBytes) {
+ return { content: text, truncated: false } as const
+ }
+
+ const out: string[] = []
+ let i = 0
+ let bytes = 0
+ let hitBytes = false
+
+ if (direction === "head") {
+ for (i = 0; i < lines.length && i < maxLines; i++) {
+ const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
+ if (bytes + size > maxBytes) {
+ hitBytes = true
+ break
+ }
+ out.push(lines[i])
+ bytes += size
+ }
+ } else {
+ for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
+ const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
+ if (bytes + size > maxBytes) {
+ hitBytes = true
+ break
+ }
+ out.unshift(lines[i])
+ bytes += size
+ }
+ }
+
+ const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
+ const unit = hitBytes ? "bytes" : "lines"
+ const preview = out.join("\n")
+ const file = path.join(TRUNCATION_DIR, ToolID.ascending())
+
+ yield* fs.makeDirectory(TRUNCATION_DIR, { recursive: true }).pipe(Effect.orDie)
+ yield* fs.writeFileString(file, text).pipe(Effect.orDie)
+
+ const hint = hasTaskTool(agent)
+ ? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
+ : `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
+
+ return {
+ content:
+ direction === "head"
+ ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
+ : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
+ truncated: true,
+ outputPath: file,
+ } as const
+ })
+
+ yield* cleanup().pipe(
+ Effect.catchCause((cause) => {
+ log.error("truncation cleanup failed", { cause: Cause.pretty(cause) })
+ return Effect.void
+ }),
+ Effect.repeat(Schedule.spaced(Duration.hours(1))),
+ Effect.delay(Duration.minutes(1)),
+ Effect.forkScoped,
+ )
+
+ return Service.of({ cleanup, output })
+ }),
+ )
+
+ export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
+}
diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts
new file mode 100644
index 000000000..622052fd4
--- /dev/null
+++ b/packages/opencode/src/tool/truncate.ts
@@ -0,0 +1,19 @@
+import type { Agent } from "../agent/agent"
+import { runtime } from "@/effect/runtime"
+import { TruncateEffect as S } from "./truncate-effect"
+
+
+export namespace Truncate {
+ export const MAX_LINES = S.MAX_LINES
+ export const MAX_BYTES = S.MAX_BYTES
+ export const DIR = S.DIR
+ export const GLOB = S.GLOB
+
+ export type Result = S.Result
+
+ export type Options = S.Options
+
+ export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
+ return runtime.runPromise(S.Service.use((s) => s.output(text, options, agent)))
+ }
+}
diff --git a/packages/opencode/src/tool/truncation-dir.ts b/packages/opencode/src/tool/truncation-dir.ts
new file mode 100644
index 000000000..d6d5d013d
--- /dev/null
+++ b/packages/opencode/src/tool/truncation-dir.ts
@@ -0,0 +1,4 @@
+import path from "path"
+import { Global } from "../global"
+
+export const TRUNCATION_DIR = path.join(Global.Path.data, "tool-output")
diff --git a/packages/opencode/src/tool/truncation.ts b/packages/opencode/src/tool/truncation.ts
deleted file mode 100644
index 7c6a362a3..000000000
--- a/packages/opencode/src/tool/truncation.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-import fs from "fs/promises"
-import path from "path"
-import { Global } from "../global"
-import { Identifier } from "../id/id"
-import { PermissionNext } from "../permission/next"
-import type { Agent } from "../agent/agent"
-import { Scheduler } from "../scheduler"
-import { Filesystem } from "../util/filesystem"
-import { Glob } from "../util/glob"
-import { ToolID } from "./schema"
-
-export namespace Truncate {
- export const MAX_LINES = 2000
- export const MAX_BYTES = 50 * 1024
- export const DIR = path.join(Global.Path.data, "tool-output")
- export const GLOB = path.join(DIR, "*")
- const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
- const HOUR_MS = 60 * 60 * 1000
-
- export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
-
- export interface Options {
- maxLines?: number
- maxBytes?: number
- direction?: "head" | "tail"
- }
-
- export function init() {
- Scheduler.register({
- id: "tool.truncation.cleanup",
- interval: HOUR_MS,
- run: cleanup,
- scope: "global",
- })
- }
-
- export async function cleanup() {
- const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - RETENTION_MS))
- const entries = await Glob.scan("tool_*", { cwd: DIR, include: "file" }).catch(() => [] as string[])
- for (const entry of entries) {
- if (Identifier.timestamp(entry) >= cutoff) continue
- await fs.unlink(path.join(DIR, entry)).catch(() => {})
- }
- }
-
- function hasTaskTool(agent?: Agent.Info): boolean {
- if (!agent?.permission) return false
- const rule = PermissionNext.evaluate("task", "*", agent.permission)
- return rule.action !== "deny"
- }
-
- export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
- const maxLines = options.maxLines ?? MAX_LINES
- const maxBytes = options.maxBytes ?? MAX_BYTES
- const direction = options.direction ?? "head"
- const lines = text.split("\n")
- const totalBytes = Buffer.byteLength(text, "utf-8")
-
- if (lines.length <= maxLines && totalBytes <= maxBytes) {
- return { content: text, truncated: false }
- }
-
- const out: string[] = []
- let i = 0
- let bytes = 0
- let hitBytes = false
-
- if (direction === "head") {
- for (i = 0; i < lines.length && i < maxLines; i++) {
- const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
- if (bytes + size > maxBytes) {
- hitBytes = true
- break
- }
- out.push(lines[i])
- bytes += size
- }
- } else {
- for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
- const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
- if (bytes + size > maxBytes) {
- hitBytes = true
- break
- }
- out.unshift(lines[i])
- bytes += size
- }
- }
-
- const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
- const unit = hitBytes ? "bytes" : "lines"
- const preview = out.join("\n")
-
- const id = ToolID.ascending()
- const filepath = path.join(DIR, id)
- await Filesystem.write(filepath, text)
-
- const hint = hasTaskTool(agent)
- ? `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
- : `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
- const message =
- direction === "head"
- ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
- : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`
-
- return { content: message, truncated: true, outputPath: filepath }
- }
-}
diff --git a/packages/opencode/test/account/repo.test.ts b/packages/opencode/test/account/repo.test.ts
index 74a6d7a57..fb12ddf70 100644
--- a/packages/opencode/test/account/repo.test.ts
+++ b/packages/opencode/test/account/repo.test.ts
@@ -4,7 +4,7 @@ import { Effect, Layer, Option } from "effect"
import { AccountRepo } from "../../src/account/repo"
import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema"
import { Database } from "../../src/storage/db"
-import { testEffect } from "../fixture/effect"
+import { testEffect } from "../lib/effect"
const truncate = Layer.effectDiscard(
Effect.sync(() => {
@@ -16,24 +16,21 @@ const truncate = Layer.effectDiscard(
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
-it.effect(
- "list returns empty when no accounts exist",
+it.effect("list returns empty when no accounts exist", () =>
Effect.gen(function* () {
const accounts = yield* AccountRepo.use((r) => r.list())
expect(accounts).toEqual([])
}),
)
-it.effect(
- "active returns none when no accounts exist",
+it.effect("active returns none when no accounts exist", () =>
Effect.gen(function* () {
const active = yield* AccountRepo.use((r) => r.active())
expect(Option.isNone(active)).toBe(true)
}),
)
-it.effect(
- "persistAccount inserts and getRow retrieves",
+it.effect("persistAccount inserts and getRow retrieves", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
@@ -59,8 +56,7 @@ it.effect(
}),
)
-it.effect(
- "persistAccount sets the active account and org",
+it.effect("persistAccount sets the active account and org", () =>
Effect.gen(function* () {
const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2")
@@ -97,8 +93,7 @@ it.effect(
}),
)
-it.effect(
- "list returns all accounts",
+it.effect("list returns all accounts", () =>
Effect.gen(function* () {
const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2")
@@ -133,8 +128,7 @@ it.effect(
}),
)
-it.effect(
- "remove deletes an account",
+it.effect("remove deletes an account", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
@@ -157,8 +151,7 @@ it.effect(
}),
)
-it.effect(
- "use stores the selected org and marks the account active",
+it.effect("use stores the selected org and marks the account active", () =>
Effect.gen(function* () {
const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2")
@@ -198,8 +191,7 @@ it.effect(
}),
)
-it.effect(
- "persistToken updates token fields",
+it.effect("persistToken updates token fields", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
@@ -233,8 +225,7 @@ it.effect(
}),
)
-it.effect(
- "persistToken with no expiry sets token_expiry to null",
+it.effect("persistToken with no expiry sets token_expiry to null", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
@@ -264,8 +255,7 @@ it.effect(
}),
)
-it.effect(
- "persistAccount upserts on conflict",
+it.effect("persistAccount upserts on conflict", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
@@ -305,8 +295,7 @@ it.effect(
}),
)
-it.effect(
- "remove clears active state when deleting the active account",
+it.effect("remove clears active state when deleting the active account", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
@@ -329,8 +318,7 @@ it.effect(
}),
)
-it.effect(
- "getRow returns none for nonexistent account",
+it.effect("getRow returns none for nonexistent account", () =>
Effect.gen(function* () {
const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope")))
expect(Option.isNone(row)).toBe(true)
diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts
index 5caa33235..ca244c2d9 100644
--- a/packages/opencode/test/account/service.test.ts
+++ b/packages/opencode/test/account/service.test.ts
@@ -1,12 +1,12 @@
import { expect } from "bun:test"
-import { Duration, Effect, Layer, Option, Ref, Schema } from "effect"
+import { Duration, Effect, Layer, Option, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AccountRepo } from "../../src/account/repo"
import { AccountService } from "../../src/account/service"
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
import { Database } from "../../src/storage/db"
-import { testEffect } from "../fixture/effect"
+import { testEffect } from "../lib/effect"
const truncate = Layer.effectDiscard(
Effect.sync(() => {
@@ -34,8 +34,7 @@ const encodeOrg = Schema.encodeSync(Org)
const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name }))
-it.effect(
- "orgsByAccount groups orgs per account",
+it.effect("orgsByAccount groups orgs per account", () =>
Effect.gen(function* () {
yield* AccountRepo.use((r) =>
r.persistAccount({
@@ -61,10 +60,10 @@ it.effect(
}),
)
- const seen = yield* Ref.make<string[]>([])
+ const seen: Array<string> = []
const client = HttpClient.make((req) =>
Effect.gen(function* () {
- yield* Ref.update(seen, (xs) => [...xs, `${req.method} ${req.url}`])
+ seen.push(`${req.method} ${req.url}`)
if (req.url === "https://one.example.com/api/orgs") {
return json(req, [org("org-1", "One")])
@@ -84,15 +83,14 @@ it.effect(
[AccountID.make("user-1"), [OrgID.make("org-1")]],
[AccountID.make("user-2"), [OrgID.make("org-2"), OrgID.make("org-3")]],
])
- expect(yield* Ref.get(seen)).toEqual([
+ expect(seen).toEqual([
"GET https://one.example.com/api/orgs",
"GET https://two.example.com/api/orgs",
])
}),
)
-it.effect(
- "token refresh persists the new token",
+it.effect("token refresh persists the new token", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
@@ -133,8 +131,7 @@ it.effect(
}),
)
-it.effect(
- "config sends the selected org header",
+it.effect("config sends the selected org header", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
@@ -150,13 +147,11 @@ it.effect(
}),
)
- const seen = yield* Ref.make<{ auth?: string; org?: string }>({})
+ const seen: { auth?: string; org?: string } = {}
const client = HttpClient.make((req) =>
Effect.gen(function* () {
- yield* Ref.set(seen, {
- auth: req.headers.authorization,
- org: req.headers["x-org-id"],
- })
+ seen.auth = req.headers.authorization
+ seen.org = req.headers["x-org-id"]
if (req.url === "https://one.example.com/api/config") {
return json(req, { config: { theme: "light", seats: 5 } })
@@ -169,15 +164,14 @@ it.effect(
const cfg = yield* AccountService.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
- expect(yield* Ref.get(seen)).toEqual({
+ expect(seen).toEqual({
auth: "Bearer at_1",
org: "org-9",
})
}),
)
-it.effect(
- "poll stores the account and first org on success",
+it.effect("poll stores the account and first org on success", () =>
Effect.gen(function* () {
const login = new Login({
code: DeviceCode.make("device-code"),
diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts
index 497b6019d..d6b6ebb33 100644
--- a/packages/opencode/test/agent/agent.test.ts
+++ b/packages/opencode/test/agent/agent.test.ts
@@ -3,7 +3,7 @@ import path from "path"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Agent } from "../../src/agent/agent"
-import { PermissionNext } from "../../src/permission/next"
+import { PermissionNext } from "../../src/permission"
// Helper to evaluate permission for a tool with wildcard pattern
function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined {
@@ -76,7 +76,7 @@ test("explore agent denies edit and write", async () => {
})
test("explore agent asks for external directories and allows Truncate.GLOB", async () => {
- const { Truncate } = await import("../../src/tool/truncation")
+ const { Truncate } = await import("../../src/tool/truncate")
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
@@ -463,7 +463,7 @@ test("legacy tools config maps write/edit/patch/multiedit to edit permission", a
})
test("Truncate.GLOB is allowed even when user denies external_directory globally", async () => {
- const { Truncate } = await import("../../src/tool/truncation")
+ const { Truncate } = await import("../../src/tool/truncate")
await using tmp = await tmpdir({
config: {
permission: {
@@ -483,7 +483,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
})
test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => {
- const { Truncate } = await import("../../src/tool/truncation")
+ const { Truncate } = await import("../../src/tool/truncate")
await using tmp = await tmpdir({
config: {
agent: {
@@ -507,7 +507,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen
})
test("explicit Truncate.GLOB deny is respected", async () => {
- const { Truncate } = await import("../../src/tool/truncation")
+ const { Truncate } = await import("../../src/tool/truncate")
await using tmp = await tmpdir({
config: {
permission: {
diff --git a/packages/opencode/test/fixture/effect.ts b/packages/opencode/test/fixture/effect.ts
deleted file mode 100644
index b75610139..000000000
--- a/packages/opencode/test/fixture/effect.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { test } from "bun:test"
-import { Effect, Layer } from "effect"
-
-export const testEffect = <R, E>(layer: Layer.Layer<R, E, never>) => ({
- effect: <A, E2>(name: string, value: Effect.Effect<A, E2, R>) =>
- test(name, () => Effect.runPromise(value.pipe(Effect.provide(layer)))),
-})
diff --git a/packages/opencode/test/lib/effect.ts b/packages/opencode/test/lib/effect.ts
new file mode 100644
index 000000000..4162ba092
--- /dev/null
+++ b/packages/opencode/test/lib/effect.ts
@@ -0,0 +1,37 @@
+import { test, type TestOptions } from "bun:test"
+import { Cause, Effect, Exit, Layer } from "effect"
+import type * as Scope from "effect/Scope"
+import * as TestConsole from "effect/testing/TestConsole"
+
+type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
+const env = TestConsole.layer
+
+const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
+
+const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2, never>) =>
+ Effect.gen(function* () {
+ const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
+ if (Exit.isFailure(exit)) {
+ for (const err of Cause.prettyErrors(exit.cause)) {
+ yield* Effect.logError(err)
+ }
+ }
+ return yield* exit
+ }).pipe(Effect.runPromise)
+
+const make = <R, E>(layer: Layer.Layer<R, E, never>) => {
+ const effect = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
+ test(name, () => run(value, layer), opts)
+
+ effect.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
+ test.only(name, () => run(value, layer), opts)
+
+ effect.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
+ test.skip(name, () => run(value, layer), opts)
+
+ return { effect }
+}
+
+export const it = make(env)
+
+export const testEffect = <R, E>(layer: Layer.Layer<R, E, never>) => make(Layer.provideMerge(layer, env))
diff --git a/packages/opencode/test/lib/filesystem.ts b/packages/opencode/test/lib/filesystem.ts
new file mode 100644
index 000000000..66f702ec3
--- /dev/null
+++ b/packages/opencode/test/lib/filesystem.ts
@@ -0,0 +1,10 @@
+import path from "path"
+import { Effect, FileSystem } from "effect"
+
+export const writeFileStringScoped = Effect.fn("test.writeFileStringScoped")(function* (file: string, text: string) {
+ const fs = yield* FileSystem.FileSystem
+ yield* fs.makeDirectory(path.dirname(file), { recursive: true })
+ yield* fs.writeFileString(file, text)
+ yield* Effect.addFinalizer(() => fs.remove(file, { force: true }).pipe(Effect.orDie))
+ return file
+})
diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts
index 3d592a3d9..c78da6e6a 100644
--- a/packages/opencode/test/permission-task.test.ts
+++ b/packages/opencode/test/permission-task.test.ts
@@ -1,5 +1,5 @@
import { describe, test, expect } from "bun:test"
-import { PermissionNext } from "../src/permission/next"
+import { PermissionNext } from "../src/permission"
import { Config } from "../src/config/config"
import { Instance } from "../src/project/instance"
import { tmpdir } from "./fixture/fixture"
diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts
index b9845ae26..6fa782b05 100644
--- a/packages/opencode/test/permission/next.test.ts
+++ b/packages/opencode/test/permission/next.test.ts
@@ -4,7 +4,7 @@ import { Effect } from "effect"
import { Bus } from "../../src/bus"
import { runtime } from "../../src/effect/runtime"
import { Instances } from "../../src/effect/instances"
-import { PermissionNext } from "../../src/permission/next"
+import { PermissionNext } from "../../src/permission"
import * as S from "../../src/permission/service"
import { PermissionID } from "../../src/permission/schema"
import { Instance } from "../../src/project/instance"
@@ -1005,7 +1005,7 @@ test("ask - abort should clear pending request", async () => {
fn: async () => {
const ctl = new AbortController()
const ask = runtime.runPromise(
- S.PermissionService.use((svc) =>
+ S.PermissionEffect.Service.use((svc) =>
svc.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
diff --git a/packages/opencode/test/scheduler.test.ts b/packages/opencode/test/scheduler.test.ts
deleted file mode 100644
index 328daad9b..000000000
--- a/packages/opencode/test/scheduler.test.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { Scheduler } from "../src/scheduler"
-import { Instance } from "../src/project/instance"
-import { tmpdir } from "./fixture/fixture"
-
-describe("Scheduler.register", () => {
- const hour = 60 * 60 * 1000
-
- test("defaults to instance scope per directory", async () => {
- await using one = await tmpdir({ git: true })
- await using two = await tmpdir({ git: true })
- const runs = { count: 0 }
- const id = "scheduler.instance." + Math.random().toString(36).slice(2)
- const task = {
- id,
- interval: hour,
- run: async () => {
- runs.count += 1
- },
- }
-
- await Instance.provide({
- directory: one.path,
- fn: async () => {
- Scheduler.register(task)
- await Instance.dispose()
- },
- })
- expect(runs.count).toBe(1)
-
- await Instance.provide({
- directory: two.path,
- fn: async () => {
- Scheduler.register(task)
- await Instance.dispose()
- },
- })
- expect(runs.count).toBe(2)
- })
-
- test("global scope runs once across instances", async () => {
- await using one = await tmpdir({ git: true })
- await using two = await tmpdir({ git: true })
- const runs = { count: 0 }
- const id = "scheduler.global." + Math.random().toString(36).slice(2)
- const task = {
- id,
- interval: hour,
- run: async () => {
- runs.count += 1
- },
- scope: "global" as const,
- }
-
- await Instance.provide({
- directory: one.path,
- fn: async () => {
- Scheduler.register(task)
- await Instance.dispose()
- },
- })
- expect(runs.count).toBe(1)
-
- await Instance.provide({
- directory: two.path,
- fn: async () => {
- Scheduler.register(task)
- await Instance.dispose()
- },
- })
- expect(runs.count).toBe(1)
- })
-})
diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts
index f947398b3..a5c7cec91 100644
--- a/packages/opencode/test/tool/bash.test.ts
+++ b/packages/opencode/test/tool/bash.test.ts
@@ -5,8 +5,8 @@ import { BashTool } from "../../src/tool/bash"
import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
-import type { PermissionNext } from "../../src/permission/next"
-import { Truncate } from "../../src/tool/truncation"
+import type { PermissionNext } from "../../src/permission"
+import { Truncate } from "../../src/tool/truncate"
import { SessionID, MessageID } from "../../src/session/schema"
const ctx = {
diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts
index 58e53e583..229901a72 100644
--- a/packages/opencode/test/tool/external-directory.test.ts
+++ b/packages/opencode/test/tool/external-directory.test.ts
@@ -3,7 +3,7 @@ import path from "path"
import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance"
import { assertExternalDirectory } from "../../src/tool/external-directory"
-import type { PermissionNext } from "../../src/permission/next"
+import type { PermissionNext } from "../../src/permission"
import { SessionID, MessageID } from "../../src/session/schema"
const baseCtx: Omit<Tool.Context, "ask"> = {
diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts
index 0761a9304..cfeb597fc 100644
--- a/packages/opencode/test/tool/read.test.ts
+++ b/packages/opencode/test/tool/read.test.ts
@@ -4,7 +4,7 @@ import { ReadTool } from "../../src/tool/read"
import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
-import { PermissionNext } from "../../src/permission/next"
+import { PermissionNext } from "../../src/permission"
import { Agent } from "../../src/agent/agent"
import { SessionID, MessageID } from "../../src/session/schema"
diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts
index 5bcdb6c2b..7cfaee135 100644
--- a/packages/opencode/test/tool/skill.test.ts
+++ b/packages/opencode/test/tool/skill.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
-import type { PermissionNext } from "../../src/permission/next"
+import type { PermissionNext } from "../../src/permission"
import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance"
import { SkillTool } from "../../src/tool/skill"
diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts
index 9e141b205..71439f760 100644
--- a/packages/opencode/test/tool/truncation.test.ts
+++ b/packages/opencode/test/tool/truncation.test.ts
@@ -1,9 +1,13 @@
-import { describe, test, expect, afterAll } from "bun:test"
-import { Truncate } from "../../src/tool/truncation"
+import { describe, test, expect } from "bun:test"
+import { NodeFileSystem } from "@effect/platform-node"
+import { Effect, FileSystem, Layer } from "effect"
+import { Truncate } from "../../src/tool/truncate"
+import { TruncateEffect } from "../../src/tool/truncate-effect"
import { Identifier } from "../../src/id/id"
import { Filesystem } from "../../src/util/filesystem"
-import fs from "fs/promises"
import path from "path"
+import { testEffect } from "../lib/effect"
+import { writeFileStringScoped } from "../lib/filesystem"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
@@ -125,36 +129,24 @@ describe("Truncate", () => {
describe("cleanup", () => {
const DAY_MS = 24 * 60 * 60 * 1000
- let oldFile: string
- let recentFile: string
+ const it = testEffect(Layer.mergeAll(TruncateEffect.defaultLayer, NodeFileSystem.layer))
- afterAll(async () => {
- await fs.unlink(oldFile).catch(() => {})
- await fs.unlink(recentFile).catch(() => {})
- })
-
- test("deletes files older than 7 days and preserves recent files", async () => {
- await fs.mkdir(Truncate.DIR, { recursive: true })
+ it.effect("deletes files older than 7 days and preserves recent files", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem
- // Create an old file (10 days ago)
- const oldTimestamp = Date.now() - 10 * DAY_MS
- const oldId = Identifier.create("tool", false, oldTimestamp)
- oldFile = path.join(Truncate.DIR, oldId)
- await Filesystem.write(oldFile, "old content")
+ yield* fs.makeDirectory(Truncate.DIR, { recursive: true })
- // Create a recent file (3 days ago)
- const recentTimestamp = Date.now() - 3 * DAY_MS
- const recentId = Identifier.create("tool", false, recentTimestamp)
- recentFile = path.join(Truncate.DIR, recentId)
- await Filesystem.write(recentFile, "recent content")
+ const old = path.join(Truncate.DIR, Identifier.create("tool", false, Date.now() - 10 * DAY_MS))
+ const recent = path.join(Truncate.DIR, Identifier.create("tool", false, Date.now() - 3 * DAY_MS))
- await Truncate.cleanup()
+ yield* writeFileStringScoped(old, "old content")
+ yield* writeFileStringScoped(recent, "recent content")
+ yield* TruncateEffect.Service.use((s) => s.cleanup())
- // Old file should be deleted
- expect(await Filesystem.exists(oldFile)).toBe(false)
-
- // Recent file should still exist
- expect(await Filesystem.exists(recentFile)).toBe(true)
- })
+ expect(yield* fs.exists(old)).toBe(false)
+ expect(yield* fs.exists(recent)).toBe(true)
+ }),
+ )
})
})