refactor(tool): destroy Truncate facade, effectify Tool.define (#22093)

This commit is contained in:
Kit Langton
2026-04-11 21:20:12 -04:00
committed by GitHub
parent 824c12c01a
commit 319b7655b7
14 changed files with 186 additions and 125 deletions
+15 -5
View File
@@ -70,7 +70,12 @@ export namespace Tool {
? Def<P, M> ? Def<P, M>
: never : never
function wrap<Parameters extends z.ZodType, Result extends Metadata>(id: string, init: Init<Parameters, Result>) { function wrap<Parameters extends z.ZodType, Result extends Metadata>(
id: string,
init: Init<Parameters, Result>,
truncate: Truncate.Interface,
agents: Agent.Interface,
) {
return () => return () =>
Effect.gen(function* () { Effect.gen(function* () {
const toolInfo = init instanceof Function ? { ...(yield* init()) } : { ...init } const toolInfo = init instanceof Function ? { ...(yield* init()) } : { ...init }
@@ -93,8 +98,8 @@ export namespace Tool {
if (result.metadata.truncated !== undefined) { if (result.metadata.truncated !== undefined) {
return result return result
} }
const agent = yield* Effect.promise(() => Agent.get(ctx.agent)) const agent = yield* agents.get(ctx.agent)
const truncated = yield* Effect.promise(() => Truncate.output(result.output, {}, agent)) const truncated = yield* truncate.output(result.output, {}, agent)
return { return {
...result, ...result,
output: truncated.content, output: truncated.content,
@@ -112,9 +117,14 @@ export namespace Tool {
export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>( export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
id: ID, id: ID,
init: Effect.Effect<Init<Parameters, Result>, never, R>, init: Effect.Effect<Init<Parameters, Result>, never, R>,
): Effect.Effect<Info<Parameters, Result>, never, R> & { id: ID } { ): Effect.Effect<Info<Parameters, Result>, never, R | Truncate.Service | Agent.Service> & { id: ID } {
return Object.assign( return Object.assign(
Effect.map(init, (init) => ({ id, init: wrap(id, init) })), Effect.gen(function* () {
const resolved = yield* init
const truncate = yield* Truncate.Service
const agents = yield* Agent.Service
return { id, init: wrap(id, resolved, truncate, agents) }
}),
{ id }, { id },
) )
} }
-7
View File
@@ -2,7 +2,6 @@ import { NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Schedule, Context } from "effect" import { Cause, Duration, Effect, Layer, Schedule, Context } from "effect"
import path from "path" import path from "path"
import type { Agent } from "../agent/agent" import type { Agent } from "../agent/agent"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { evaluate } from "@/permission/evaluate" import { evaluate } from "@/permission/evaluate"
import { Identifier } from "../id/id" import { Identifier } from "../id/id"
@@ -135,10 +134,4 @@ export namespace Truncate {
) )
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer)) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
return runPromise((s) => s.output(text, options, agent))
}
} }
@@ -7,12 +7,14 @@ import { Instance } from "../../src/project/instance"
import { LSP } from "../../src/lsp" import { LSP } from "../../src/lsp"
import { AppFileSystem } from "../../src/filesystem" import { AppFileSystem } from "../../src/filesystem"
import { Format } from "../../src/format" import { Format } from "../../src/format"
import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { Truncate } from "../../src/tool/truncate"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
const runtime = ManagedRuntime.make( const runtime = ManagedRuntime.make(
Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer), Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer, Truncate.defaultLayer, Agent.defaultLayer),
) )
const baseCtx = { const baseCtx = {
+8 -1
View File
@@ -8,6 +8,7 @@ import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import type { Permission } from "../../src/permission" import type { Permission } from "../../src/permission"
import { Agent } from "../../src/agent/agent"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool/truncate"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
@@ -15,7 +16,13 @@ import { AppFileSystem } from "../../src/filesystem"
import { Plugin } from "../../src/plugin" import { Plugin } from "../../src/plugin"
const runtime = ManagedRuntime.make( const runtime = ManagedRuntime.make(
Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer, Plugin.defaultLayer), Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
AppFileSystem.defaultLayer,
Plugin.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
),
) )
function initBash() { function initBash() {
+3 -1
View File
@@ -9,8 +9,10 @@ import { FileTime } from "../../src/file/time"
import { LSP } from "../../src/lsp" import { LSP } from "../../src/lsp"
import { AppFileSystem } from "../../src/filesystem" import { AppFileSystem } from "../../src/filesystem"
import { Format } from "../../src/format" import { Format } from "../../src/format"
import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event" import { BusEvent } from "../../src/bus/bus-event"
import { Truncate } from "../../src/tool/truncate"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
const ctx = { const ctx = {
@@ -34,7 +36,7 @@ async function touch(file: string, time: number) {
} }
const runtime = ManagedRuntime.make( const runtime = ManagedRuntime.make(
Layer.mergeAll(LSP.defaultLayer, FileTime.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer), Layer.mergeAll(LSP.defaultLayer, FileTime.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer, Truncate.defaultLayer, Agent.defaultLayer),
) )
afterAll(async () => { afterAll(async () => {
+3 -1
View File
@@ -6,8 +6,10 @@ import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Truncate } from "../../src/tool/truncate"
import { Agent } from "../../src/agent/agent"
const runtime = ManagedRuntime.make(Layer.mergeAll(CrossSpawnSpawner.defaultLayer)) const runtime = ManagedRuntime.make(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer))
function initGrep() { function initGrep() {
return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => info.init()))) return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => info.init())))
+3 -1
View File
@@ -4,7 +4,9 @@ import { Tool } from "../../src/tool/tool"
import { QuestionTool } from "../../src/tool/question" import { QuestionTool } from "../../src/tool/question"
import { Question } from "../../src/question" import { Question } from "../../src/question"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import { Agent } from "../../src/agent/agent"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Truncate } from "../../src/tool/truncate"
import { provideTmpdirInstance } from "../fixture/fixture" import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@@ -19,7 +21,7 @@ const ctx = {
ask: () => Effect.void, ask: () => Effect.void,
} }
const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer)) const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer))
const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) { const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) {
for (;;) { for (;;) {
+2
View File
@@ -11,6 +11,7 @@ import { Instance } from "../../src/project/instance"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import { Instruction } from "../../src/session/instruction" import { Instruction } from "../../src/session/instruction"
import { ReadTool } from "../../src/tool/read" import { ReadTool } from "../../src/tool/read"
import { Truncate } from "../../src/tool/truncate"
import { Tool } from "../../src/tool/tool" import { Tool } from "../../src/tool/tool"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import { provideInstance, tmpdirScoped } from "../fixture/fixture" import { provideInstance, tmpdirScoped } from "../fixture/fixture"
@@ -41,6 +42,7 @@ const it = testEffect(
FileTime.defaultLayer, FileTime.defaultLayer,
Instruction.defaultLayer, Instruction.defaultLayer,
LSP.defaultLayer, LSP.defaultLayer,
Truncate.defaultLayer,
), ),
) )
+3 -1
View File
@@ -1,6 +1,8 @@
import { Effect, Layer, ManagedRuntime } from "effect" import { Effect, Layer, ManagedRuntime } from "effect"
import { Agent } from "../../src/agent/agent"
import { Skill } from "../../src/skill" import { Skill } from "../../src/skill"
import { Ripgrep } from "../../src/file/ripgrep" import { Ripgrep } from "../../src/file/ripgrep"
import { Truncate } from "../../src/tool/truncate"
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
@@ -150,7 +152,7 @@ Use this skill.
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const runtime = ManagedRuntime.make(Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer)) const runtime = ManagedRuntime.make(Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer))
const info = await runtime.runPromise(SkillTool) const info = await runtime.runPromise(SkillTool)
const tool = await runtime.runPromise(info.init()) const tool = await runtime.runPromise(info.init())
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+2
View File
@@ -10,6 +10,7 @@ import type { SessionPrompt } from "../../src/session/prompt"
import { MessageID, PartID } from "../../src/session/schema" import { MessageID, PartID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import { TaskTool, type TaskPromptOps } from "../../src/tool/task" import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
import { Truncate } from "../../src/tool/truncate"
import { ToolRegistry } from "../../src/tool/registry" import { ToolRegistry } from "../../src/tool/registry"
import { provideTmpdirInstance } from "../fixture/fixture" import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@@ -29,6 +30,7 @@ const it = testEffect(
Config.defaultLayer, Config.defaultLayer,
CrossSpawnSpawner.defaultLayer, CrossSpawnSpawner.defaultLayer,
Session.defaultLayer, Session.defaultLayer,
Truncate.defaultLayer,
ToolRegistry.defaultLayer, ToolRegistry.defaultLayer,
), ),
) )
@@ -1,7 +1,11 @@
import { describe, test, expect } from "bun:test" import { describe, test, expect } from "bun:test"
import { Effect } from "effect" import { Effect, Layer, ManagedRuntime } from "effect"
import z from "zod" import z from "zod"
import { Agent } from "../../src/agent/agent"
import { Tool } from "../../src/tool/tool" import { Tool } from "../../src/tool/tool"
import { Truncate } from "../../src/tool/truncate"
const runtime = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer))
const params = z.object({ input: z.string() }) const params = z.object({ input: z.string() })
@@ -21,7 +25,7 @@ describe("Tool.define", () => {
const original = makeTool("test") const original = makeTool("test")
const originalExecute = original.execute const originalExecute = original.execute
const info = await Effect.runPromise(Tool.define("test-tool", Effect.succeed(original))) const info = await runtime.runPromise(Tool.define("test-tool", Effect.succeed(original)))
await Effect.runPromise(info.init()) await Effect.runPromise(info.init())
await Effect.runPromise(info.init()) await Effect.runPromise(info.init())
@@ -31,7 +35,7 @@ describe("Tool.define", () => {
}) })
test("effect-defined tool returns fresh objects and is unaffected", async () => { test("effect-defined tool returns fresh objects and is unaffected", async () => {
const info = await Effect.runPromise( const info = await runtime.runPromise(
Tool.define( Tool.define(
"test-fn-tool", "test-fn-tool",
Effect.succeed(() => Effect.succeed(makeTool("test"))), Effect.succeed(() => Effect.succeed(makeTool("test"))),
@@ -45,7 +49,7 @@ describe("Tool.define", () => {
}) })
test("object-defined tool returns distinct objects per init() call", async () => { test("object-defined tool returns distinct objects per init() call", async () => {
const info = await Effect.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test")))) const info = await runtime.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test"))))
const first = await Effect.runPromise(info.init()) const first = await Effect.runPromise(info.init())
const second = await Effect.runPromise(info.init()) const second = await Effect.runPromise(info.init())
+123 -88
View File
@@ -1,7 +1,7 @@
import { describe, test, expect } from "bun:test" import { describe, test, expect } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { Effect, FileSystem, Layer } from "effect" import { Effect, FileSystem, Layer } from "effect"
import { Truncate, Truncate as TruncateSvc } from "../../src/tool/truncate" import { Truncate } from "../../src/tool/truncate"
import { Identifier } from "../../src/id/id" import { Identifier } from "../../src/id/id"
import { Process } from "../../src/util/process" import { Process } from "../../src/util/process"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
@@ -12,120 +12,155 @@ import { writeFileStringScoped } from "../lib/filesystem"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
const ROOT = path.resolve(import.meta.dir, "..", "..") const ROOT = path.resolve(import.meta.dir, "..", "..")
const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer))
describe("Truncate", () => { describe("Truncate", () => {
describe("output", () => { describe("output", () => {
test("truncates large json file by bytes", async () => { it.live("truncates large json file by bytes", () =>
const content = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")) Effect.gen(function* () {
const result = await Truncate.output(content) const svc = yield* Truncate.Service
const content = yield* Effect.promise(() => Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")))
const result = yield* svc.output(content)
expect(result.truncated).toBe(true) expect(result.truncated).toBe(true)
expect(result.content).toContain("truncated...") expect(result.content).toContain("truncated...")
if (result.truncated) expect(result.outputPath).toBeDefined() if (result.truncated) expect(result.outputPath).toBeDefined()
}) }),
)
test("returns content unchanged when under limits", async () => { it.live("returns content unchanged when under limits", () =>
const content = "line1\nline2\nline3" Effect.gen(function* () {
const result = await Truncate.output(content) const svc = yield* Truncate.Service
const content = "line1\nline2\nline3"
const result = yield* svc.output(content)
expect(result.truncated).toBe(false) expect(result.truncated).toBe(false)
expect(result.content).toBe(content) expect(result.content).toBe(content)
}) }),
)
test("truncates by line count", async () => { it.live("truncates by line count", () =>
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") Effect.gen(function* () {
const result = await Truncate.output(lines, { maxLines: 10 }) const svc = yield* Truncate.Service
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const result = yield* svc.output(lines, { maxLines: 10 })
expect(result.truncated).toBe(true) expect(result.truncated).toBe(true)
expect(result.content).toContain("...90 lines truncated...") expect(result.content).toContain("...90 lines truncated...")
}) }),
)
test("truncates by byte count", async () => { it.live("truncates by byte count", () =>
const content = "a".repeat(1000) Effect.gen(function* () {
const result = await Truncate.output(content, { maxBytes: 100 }) const svc = yield* Truncate.Service
const content = "a".repeat(1000)
const result = yield* svc.output(content, { maxBytes: 100 })
expect(result.truncated).toBe(true) expect(result.truncated).toBe(true)
expect(result.content).toContain("truncated...") expect(result.content).toContain("truncated...")
}) }),
)
test("truncates from head by default", async () => { it.live("truncates from head by default", () =>
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n") Effect.gen(function* () {
const result = await Truncate.output(lines, { maxLines: 3 }) const svc = yield* Truncate.Service
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
const result = yield* svc.output(lines, { maxLines: 3 })
expect(result.truncated).toBe(true) expect(result.truncated).toBe(true)
expect(result.content).toContain("line0") expect(result.content).toContain("line0")
expect(result.content).toContain("line1") expect(result.content).toContain("line1")
expect(result.content).toContain("line2") expect(result.content).toContain("line2")
expect(result.content).not.toContain("line9") expect(result.content).not.toContain("line9")
}) }),
)
test("truncates from tail when direction is tail", async () => { it.live("truncates from tail when direction is tail", () =>
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n") Effect.gen(function* () {
const result = await Truncate.output(lines, { maxLines: 3, direction: "tail" }) const svc = yield* Truncate.Service
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
const result = yield* svc.output(lines, { maxLines: 3, direction: "tail" })
expect(result.truncated).toBe(true) expect(result.truncated).toBe(true)
expect(result.content).toContain("line7") expect(result.content).toContain("line7")
expect(result.content).toContain("line8") expect(result.content).toContain("line8")
expect(result.content).toContain("line9") expect(result.content).toContain("line9")
expect(result.content).not.toContain("line0") expect(result.content).not.toContain("line0")
}) }),
)
test("uses default MAX_LINES and MAX_BYTES", () => { test("uses default MAX_LINES and MAX_BYTES", () => {
expect(Truncate.MAX_LINES).toBe(2000) expect(Truncate.MAX_LINES).toBe(2000)
expect(Truncate.MAX_BYTES).toBe(50 * 1024) expect(Truncate.MAX_BYTES).toBe(50 * 1024)
}) })
test("large single-line file truncates with byte message", async () => { it.live("large single-line file truncates with byte message", () =>
const content = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")) Effect.gen(function* () {
const result = await Truncate.output(content) const svc = yield* Truncate.Service
const content = yield* Effect.promise(() => Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")))
const result = yield* svc.output(content)
expect(result.truncated).toBe(true) expect(result.truncated).toBe(true)
expect(result.content).toContain("bytes truncated...") expect(result.content).toContain("bytes truncated...")
expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES) expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
}) }),
)
test("writes full output to file when truncated", async () => { it.live("writes full output to file when truncated", () =>
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") Effect.gen(function* () {
const result = await Truncate.output(lines, { maxLines: 10 }) const svc = yield* Truncate.Service
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const result = yield* svc.output(lines, { maxLines: 10 })
expect(result.truncated).toBe(true) expect(result.truncated).toBe(true)
expect(result.content).toContain("The tool call succeeded but the output was truncated") expect(result.content).toContain("The tool call succeeded but the output was truncated")
expect(result.content).toContain("Grep") expect(result.content).toContain("Grep")
if (!result.truncated) throw new Error("expected truncated") if (!result.truncated) throw new Error("expected truncated")
expect(result.outputPath).toBeDefined() expect(result.outputPath).toBeDefined()
expect(result.outputPath).toContain("tool_") expect(result.outputPath).toContain("tool_")
const written = await Filesystem.readText(result.outputPath!) const written = yield* Effect.promise(() => Filesystem.readText(result.outputPath!))
expect(written).toBe(lines) expect(written).toBe(lines)
}) }),
)
test("suggests Task tool when agent has task permission", async () => { it.live("suggests Task tool when agent has task permission", () =>
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") Effect.gen(function* () {
const agent = { permission: [{ permission: "task", pattern: "*", action: "allow" as const }] } const svc = yield* Truncate.Service
const result = await Truncate.output(lines, { maxLines: 10 }, agent as any) const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const agent = { permission: [{ permission: "task", pattern: "*", action: "allow" as const }] }
const result = yield* svc.output(lines, { maxLines: 10 }, agent as any)
expect(result.truncated).toBe(true) expect(result.truncated).toBe(true)
expect(result.content).toContain("Grep") expect(result.content).toContain("Grep")
expect(result.content).toContain("Task tool") expect(result.content).toContain("Task tool")
}) }),
)
test("omits Task tool hint when agent lacks task permission", async () => { it.live("omits Task tool hint when agent lacks task permission", () =>
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") Effect.gen(function* () {
const agent = { permission: [{ permission: "task", pattern: "*", action: "deny" as const }] } const svc = yield* Truncate.Service
const result = await Truncate.output(lines, { maxLines: 10 }, agent as any) const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const agent = { permission: [{ permission: "task", pattern: "*", action: "deny" as const }] }
const result = yield* svc.output(lines, { maxLines: 10 }, agent as any)
expect(result.truncated).toBe(true) expect(result.truncated).toBe(true)
expect(result.content).toContain("Grep") expect(result.content).toContain("Grep")
expect(result.content).not.toContain("Task tool") expect(result.content).not.toContain("Task tool")
}) }),
)
test("does not write file when not truncated", async () => { it.live("does not write file when not truncated", () =>
const content = "short content" Effect.gen(function* () {
const result = await Truncate.output(content) const svc = yield* Truncate.Service
const content = "short content"
const result = yield* svc.output(content)
expect(result.truncated).toBe(false) expect(result.truncated).toBe(false)
if (result.truncated) throw new Error("expected not truncated") if (result.truncated) throw new Error("expected not truncated")
expect("outputPath" in result).toBe(false) expect("outputPath" in result).toBe(false)
}) }),
)
test("loads truncate effect in a fresh process", async () => { test("loads truncate effect in a fresh process", async () => {
const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate.ts")], { const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate.ts")], {
@@ -138,10 +173,10 @@ describe("Truncate", () => {
describe("cleanup", () => { describe("cleanup", () => {
const DAY_MS = 24 * 60 * 60 * 1000 const DAY_MS = 24 * 60 * 60 * 1000
const it = testEffect(Layer.mergeAll(TruncateSvc.defaultLayer, NodeFileSystem.layer))
it.live("deletes files older than 7 days and preserves recent files", () => it.live("deletes files older than 7 days and preserves recent files", () =>
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* Truncate.Service
const fs = yield* FileSystem.FileSystem const fs = yield* FileSystem.FileSystem
yield* fs.makeDirectory(Truncate.DIR, { recursive: true }) yield* fs.makeDirectory(Truncate.DIR, { recursive: true })
@@ -151,7 +186,7 @@ describe("Truncate", () => {
yield* writeFileStringScoped(old, "old content") yield* writeFileStringScoped(old, "old content")
yield* writeFileStringScoped(recent, "recent content") yield* writeFileStringScoped(recent, "recent content")
yield* TruncateSvc.Service.use((s) => s.cleanup()) yield* svc.cleanup()
expect(yield* fs.exists(old)).toBe(false) expect(yield* fs.exists(old)).toBe(false)
expect(yield* fs.exists(recent)).toBe(true) expect(yield* fs.exists(recent)).toBe(true)
+9 -15
View File
@@ -1,7 +1,9 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { Effect } from "effect" import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http" import { FetchHttpClient } from "effect/unstable/http"
import { Agent } from "../../src/agent/agent"
import { Truncate } from "../../src/tool/truncate"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { WebFetchTool } from "../../src/tool/webfetch" import { WebFetchTool } from "../../src/tool/webfetch"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
@@ -24,10 +26,11 @@ async function withFetch(fetch: (req: Request) => Response | Promise<Response>,
await fn(server.url) await fn(server.url)
} }
function initTool() { function exec(args: { url: string; format: "text" | "markdown" | "html" }) {
return WebFetchTool.pipe( return WebFetchTool.pipe(
Effect.flatMap((info) => info.init()), Effect.flatMap((info) => info.init()),
Effect.provide(FetchHttpClient.layer), Effect.flatMap((tool) => tool.execute(args, ctx)),
Effect.provide(Layer.mergeAll(FetchHttpClient.layer, Truncate.defaultLayer, Agent.defaultLayer)),
Effect.runPromise, Effect.runPromise,
) )
} }
@@ -41,10 +44,7 @@ describe("tool.webfetch", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const webfetch = await initTool() const result = await exec({ url: new URL("/image.png", url).toString(), format: "markdown" })
const result = await Effect.runPromise(
webfetch.execute({ url: new URL("/image.png", url).toString(), format: "markdown" }, ctx),
)
expect(result.output).toBe("Image fetched successfully") expect(result.output).toBe("Image fetched successfully")
expect(result.attachments).toBeDefined() expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1) expect(result.attachments?.length).toBe(1)
@@ -72,10 +72,7 @@ describe("tool.webfetch", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const webfetch = await initTool() const result = await exec({ url: new URL("/image.svg", url).toString(), format: "html" })
const result = await Effect.runPromise(
webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx),
)
expect(result.output).toContain("<svg") expect(result.output).toContain("<svg")
expect(result.attachments).toBeUndefined() expect(result.attachments).toBeUndefined()
}, },
@@ -95,10 +92,7 @@ describe("tool.webfetch", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const webfetch = await initTool() const result = await exec({ url: new URL("/file.txt", url).toString(), format: "text" })
const result = await Effect.runPromise(
webfetch.execute({ url: new URL("/file.txt", url).toString(), format: "text" }, ctx),
)
expect(result.output).toBe("hello from webfetch") expect(result.output).toBe("hello from webfetch")
expect(result.attachments).toBeUndefined() expect(result.attachments).toBeUndefined()
}, },
@@ -9,7 +9,9 @@ import { AppFileSystem } from "../../src/filesystem"
import { FileTime } from "../../src/file/time" import { FileTime } from "../../src/file/time"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { Format } from "../../src/format" import { Format } from "../../src/format"
import { Truncate } from "../../src/tool/truncate"
import { Tool } from "../../src/tool/tool" import { Tool } from "../../src/tool/tool"
import { Agent } from "../../src/agent/agent"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { provideTmpdirInstance } from "../fixture/fixture" import { provideTmpdirInstance } from "../fixture/fixture"
@@ -38,6 +40,8 @@ const it = testEffect(
Bus.layer, Bus.layer,
Format.defaultLayer, Format.defaultLayer,
CrossSpawnSpawner.defaultLayer, CrossSpawnSpawner.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
), ),
) )