Преглед изворни кода

refactor(truncation): effectify TruncateService, delete Scheduler (#17957)

Kit Langton пре 1 месец
родитељ
комит
5dfe86dcb1
40 измењених фајлова са 406 додато и 483 уклоњено
  1. 2 2
      packages/opencode/src/agent/agent.ts
  2. 1 1
      packages/opencode/src/cli/cmd/debug/agent.ts
  3. 1 1
      packages/opencode/src/cli/cmd/run.ts
  4. 3 7
      packages/opencode/src/effect/instances.ts
  5. 6 1
      packages/opencode/src/effect/runtime.ts
  6. 5 7
      packages/opencode/src/permission/index.ts
  7. 103 110
      packages/opencode/src/permission/service.ts
  8. 0 4
      packages/opencode/src/project/bootstrap.ts
  9. 0 61
      packages/opencode/src/scheduler/index.ts
  10. 1 1
      packages/opencode/src/server/routes/permission.ts
  11. 1 1
      packages/opencode/src/server/routes/session.ts
  12. 1 1
      packages/opencode/src/session/index.ts
  13. 1 1
      packages/opencode/src/session/llm.ts
  14. 1 1
      packages/opencode/src/session/processor.ts
  15. 2 2
      packages/opencode/src/session/prompt.ts
  16. 1 1
      packages/opencode/src/session/session.sql.ts
  17. 1 1
      packages/opencode/src/session/system.ts
  18. 1 1
      packages/opencode/src/skill/skill.ts
  19. 1 1
      packages/opencode/src/tool/bash.ts
  20. 1 1
      packages/opencode/src/tool/registry.ts
  21. 1 1
      packages/opencode/src/tool/task.ts
  22. 2 2
      packages/opencode/src/tool/tool.ts
  23. 140 0
      packages/opencode/src/tool/truncate-effect.ts
  24. 19 0
      packages/opencode/src/tool/truncate.ts
  25. 4 0
      packages/opencode/src/tool/truncation-dir.ts
  26. 0 108
      packages/opencode/src/tool/truncation.ts
  27. 13 25
      packages/opencode/test/account/repo.test.ts
  28. 13 19
      packages/opencode/test/account/service.test.ts
  29. 5 5
      packages/opencode/test/agent/agent.test.ts
  30. 0 7
      packages/opencode/test/fixture/effect.ts
  31. 37 0
      packages/opencode/test/lib/effect.ts
  32. 10 0
      packages/opencode/test/lib/filesystem.ts
  33. 1 1
      packages/opencode/test/permission-task.test.ts
  34. 2 2
      packages/opencode/test/permission/next.test.ts
  35. 0 73
      packages/opencode/test/scheduler.test.ts
  36. 2 2
      packages/opencode/test/tool/bash.test.ts
  37. 1 1
      packages/opencode/test/tool/external-directory.test.ts
  38. 1 1
      packages/opencode/test/tool/read.test.ts
  39. 1 1
      packages/opencode/test/tool/skill.test.ts
  40. 21 29
      packages/opencode/test/tool/truncation.test.ts

+ 2 - 2
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"

+ 1 - 1
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"

+ 1 - 1
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"

+ 3 - 7
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))
-  }
 }

+ 6 - 1
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>) {

+ 5 - 7
packages/opencode/src/permission/next.ts → 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 {

+ 103 - 110
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: "*" }
-}

+ 0 - 4
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) {

+ 0 - 61
packages/opencode/src/scheduler/index.ts

@@ -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 })
-    })
-  }
-}

+ 1 - 1
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"

+ 1 - 1
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"

+ 1 - 1
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"

+ 1 - 1
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 {

+ 1 - 1
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"

+ 2 - 2
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

+ 1 - 1
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"

+ 1 - 1
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 {

+ 1 - 1
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"

+ 1 - 1
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

+ 1 - 1
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"

+ 1 - 1
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"),

+ 2 - 2
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 {

+ 140 - 0
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))
+}

+ 19 - 0
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)))
+  }
+}

+ 4 - 0
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")

+ 0 - 108
packages/opencode/src/tool/truncation.ts

@@ -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 }
-  }
-}

+ 13 - 25
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)

+ 13 - 19
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"),

+ 5 - 5
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: {

+ 0 - 7
packages/opencode/test/fixture/effect.ts

@@ -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)))),
-})

+ 37 - 0
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))

+ 10 - 0
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
+})

+ 1 - 1
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"

+ 2 - 2
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",

+ 0 - 73
packages/opencode/test/scheduler.test.ts

@@ -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)
-  })
-})

+ 2 - 2
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 = {

+ 1 - 1
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"> = {

+ 1 - 1
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"
 

+ 1 - 1
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"

+ 21 - 29
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)
+      }),
+    )
   })
 })