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 { return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory)))) } - - static invalidate(directory: string): Effect.Effect { - 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(effect: Effect.Effect) { diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/index.ts similarity index 86% rename from packages/opencode/src/permission/next.ts rename to 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 namespace PermissionEffect { + const log = Log.create({ service: "permission" }) -export const Action = z.enum(["allow", "deny", "ask"]).meta({ - ref: "PermissionAction", -}) -export type Action = z.infer - -export const Rule = z - .object({ - permission: z.string(), - pattern: z.string(), - action: Action, + export const Action = z.enum(["allow", "deny", "ask"]).meta({ + ref: "PermissionAction", }) - .meta({ - ref: "PermissionRule", + export type Action = z.infer + + export const Rule = z + .object({ + permission: z.string(), + pattern: z.string(), + action: Action, + }) + .meta({ + ref: "PermissionRule", + }) + export type Rule = z.infer + + export const Ruleset = Rule.array().meta({ + ref: "PermissionRuleset", }) -export type Rule = z.infer + export type Ruleset = z.infer -export const Ruleset = Rule.array().meta({ - ref: "PermissionRuleset", -}) -export type Ruleset = z.infer - -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(), - }) - .meta({ - ref: "PermissionRequest", - }) -export type Request = z.infer - -export const Reply = z.enum(["once", "always", "reject"]) -export type Reply = z.infer - -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 -export class RejectedError extends Schema.TaggedErrorClass()("PermissionRejectedError", {}) { - override get message() { - return "The user rejected permission to use this specific tool call." + export const Reply = z.enum(["once", "always", "reject"]) + export type Reply = z.infer + + 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({ + sessionID: SessionID.zod, + requestID: PermissionID.zod, + reply: Reply, + }), + ), } -} -export class CorrectedError extends Schema.TaggedErrorClass()("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()("PermissionRejectedError", {}) { + override get message() { + return "The user rejected permission to use this specific tool call." + } } -} -export class DeniedError extends Schema.TaggedErrorClass()("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()("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()("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 -} + 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) => Effect.Effect + readonly ask: (input: z.infer) => Effect.Effect readonly reply: (input: z.infer) => Effect.Effect readonly list: () => Effect.Effect } -} -export class PermissionService extends ServiceMap.Service()( - "@opencode/PermissionNext", -) { - static readonly layer = Layer.effect( - PermissionService, + interface PendingEntry { + info: Request + deferred: Deferred.Deferred + } + + 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()("@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 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 - scope?: "instance" | "global" - } - - type Timer = ReturnType - type Entry = { - tasks: Map - timers: Map - } - - const create = (): Entry => { - const tasks = new Map() - const timers = new Map() - 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 + /** + * 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 + } + + export class Service extends ServiceMap.Service()("@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 { + 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 { - 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([]) + const seen: Array = [] 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 = (layer: Layer.Layer) => ({ - effect: (name: string, value: Effect.Effect) => - 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 = Effect.Effect | (() => Effect.Effect) +const env = TestConsole.layer + +const body = (value: Body) => Effect.suspend(() => (typeof value === "function" ? value() : value)) + +const run = (value: Body, layer: Layer.Layer) => + 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 = (layer: Layer.Layer) => { + const effect = (name: string, value: Body, opts?: number | TestOptions) => + test(name, () => run(value, layer), opts) + + effect.only = (name: string, value: Body, opts?: number | TestOptions) => + test.only(name, () => run(value, layer), opts) + + effect.skip = (name: string, value: Body, opts?: number | TestOptions) => + test.skip(name, () => run(value, layer), opts) + + return { effect } +} + +export const it = make(env) + +export const testEffect = (layer: Layer.Layer) => 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 = { 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(() => {}) - }) + it.effect("deletes files older than 7 days and preserves recent files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem - test("deletes files older than 7 days and preserves recent files", async () => { - await fs.mkdir(Truncate.DIR, { recursive: true }) + yield* fs.makeDirectory(Truncate.DIR, { recursive: true }) - // 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") + 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)) - // 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") + yield* writeFileStringScoped(old, "old content") + yield* writeFileStringScoped(recent, "recent content") + yield* TruncateEffect.Service.use((s) => s.cleanup()) - await Truncate.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) + }), + ) }) })