+21

![opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)



![opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)



James Long
Brendan Allan
Kit Langton
opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Affan Ali
affanali2k3
Frank
opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴
Aiden Cline
Jay V
Dax Raad
Aarav Sareen
OpeOginni
Luke Parker
Ben Guthrie
Dax
Filip
Max Anderson
Brendan Allan
Jack
Shoubhit Dash
Dustin Deus
starptech
Aiden Cline
usrnk1
Jay
runvip
opencode
Julian Coy
Vladimir Glafirov
8c94e9005f
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Affan Ali <93028901+affanali2k3@users.noreply.github.com> Co-authored-by: affanali2k3 <affanalikhanxx@gmail.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Jay V <air@live.ca> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: Ben Guthrie <benjee.012@gmail.com> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com> Co-authored-by: Max Anderson <max.a.anderson95@gmail.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: runvip <164729189+runvip@users.noreply.github.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Julian Coy <julian@ex-machina.co> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
265 lines
10 KiB
TypeScript
265 lines
10 KiB
TypeScript
import { describe, test, expect } from "bun:test"
|
|
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|
import { filesystem } from "@opencode-ai/core/effect/app-node-platform"
|
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
|
import { Effect, FileSystem } from "effect"
|
|
import { Truncate } from "@/tool/truncate"
|
|
import { Config } from "@/config/config"
|
|
import { Identifier } from "../../src/id/id"
|
|
import { Process } from "@/util/process"
|
|
import path from "path"
|
|
import { testEffect } from "../lib/effect"
|
|
import { writeFileStringScoped } from "../lib/filesystem"
|
|
import { TestConfig } from "../fixture/config"
|
|
|
|
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
|
const ROOT = path.resolve(import.meta.dir, "..", "..")
|
|
|
|
const it = testEffect(LayerNode.compile(LayerNode.group([Truncate.node, FSUtil.node, filesystem])))
|
|
|
|
const configuredLayer = (cfg: ConfigV1.Info) =>
|
|
LayerNode.compile(LayerNode.group([Truncate.node, FSUtil.node, filesystem, Config.node]), [
|
|
[Config.node, TestConfig.layer({ get: () => Effect.succeed(cfg) })],
|
|
])
|
|
const configuredIt = (cfg: ConfigV1.Info) => testEffect(configuredLayer(cfg))
|
|
|
|
describe("Truncate", () => {
|
|
describe("output", () => {
|
|
it.live("truncates large json file by bytes", () =>
|
|
Effect.gen(function* () {
|
|
const svc = yield* Truncate.Service
|
|
const fsys = yield* FSUtil.Service
|
|
const content = yield* fsys.readFileString(path.join(FIXTURES_DIR, "models-api.json"))
|
|
const result = yield* svc.output(content)
|
|
|
|
expect(result.truncated).toBe(true)
|
|
expect(result.content).toContain("truncated...")
|
|
if (result.truncated) expect(result.outputPath).toBeDefined()
|
|
}),
|
|
)
|
|
|
|
it.live("returns content unchanged when under limits", () =>
|
|
Effect.gen(function* () {
|
|
const svc = yield* Truncate.Service
|
|
const content = "line1\nline2\nline3"
|
|
const result = yield* svc.output(content)
|
|
|
|
expect(result.truncated).toBe(false)
|
|
expect(result.content).toBe(content)
|
|
}),
|
|
)
|
|
|
|
it.live("truncates by line count", () =>
|
|
Effect.gen(function* () {
|
|
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.content).toContain("...90 lines truncated...")
|
|
}),
|
|
)
|
|
|
|
it.live("truncates by byte count", () =>
|
|
Effect.gen(function* () {
|
|
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.content).toContain("truncated...")
|
|
}),
|
|
)
|
|
|
|
it.live("truncates from head by default", () =>
|
|
Effect.gen(function* () {
|
|
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.content).toContain("line0")
|
|
expect(result.content).toContain("line1")
|
|
expect(result.content).toContain("line2")
|
|
expect(result.content).not.toContain("line9")
|
|
}),
|
|
)
|
|
|
|
it.live("truncates from tail when direction is tail", () =>
|
|
Effect.gen(function* () {
|
|
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.content).toContain("line7")
|
|
expect(result.content).toContain("line8")
|
|
expect(result.content).toContain("line9")
|
|
expect(result.content).not.toContain("line0")
|
|
}),
|
|
)
|
|
|
|
test("uses default MAX_LINES and MAX_BYTES", () => {
|
|
expect(Truncate.MAX_LINES).toBe(2000)
|
|
expect(Truncate.MAX_BYTES).toBe(50 * 1024)
|
|
})
|
|
|
|
it.live("limits() falls back to MAX_LINES/MAX_BYTES when Config is not provided", () =>
|
|
Effect.gen(function* () {
|
|
const svc = yield* Truncate.Service
|
|
const resolved = yield* svc.limits()
|
|
expect(resolved.maxLines).toBe(Truncate.MAX_LINES)
|
|
expect(resolved.maxBytes).toBe(Truncate.MAX_BYTES)
|
|
}),
|
|
)
|
|
|
|
describe("with tool_output config", () => {
|
|
const limitsIt = configuredIt({ tool_output: { max_lines: 123, max_bytes: 456 } })
|
|
limitsIt.live("limits() reflects config overrides", () =>
|
|
Effect.gen(function* () {
|
|
const resolved = yield* (yield* Truncate.Service).limits()
|
|
expect(resolved.maxLines).toBe(123)
|
|
expect(resolved.maxBytes).toBe(456)
|
|
}),
|
|
)
|
|
|
|
// Huge byte budget isolates line truncation. 100 lines against max_lines: 10
|
|
// proves the configured line limit is what `output()` enforces.
|
|
const lineIt = configuredIt({ tool_output: { max_lines: 10, max_bytes: 1024 * 1024 } })
|
|
lineIt.live("output() truncates to configured max_lines", () =>
|
|
Effect.gen(function* () {
|
|
const content = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
|
const result = yield* (yield* Truncate.Service).output(content)
|
|
expect(result.truncated).toBe(true)
|
|
expect(result.content).toContain("...90 lines truncated...")
|
|
}),
|
|
)
|
|
|
|
// Huge line budget isolates byte truncation.
|
|
const byteIt = configuredIt({ tool_output: { max_lines: 1_000_000, max_bytes: 100 } })
|
|
byteIt.live("output() truncates to configured max_bytes", () =>
|
|
Effect.gen(function* () {
|
|
const content = "a".repeat(1000)
|
|
const result = yield* (yield* Truncate.Service).output(content)
|
|
expect(result.truncated).toBe(true)
|
|
expect(result.content).toContain("bytes truncated...")
|
|
}),
|
|
)
|
|
|
|
const overrideIt = configuredIt({ tool_output: { max_lines: 10, max_bytes: 100 } })
|
|
overrideIt.live("per-call options still override config", () =>
|
|
Effect.gen(function* () {
|
|
const content = Array.from({ length: 50 }, (_, i) => `line${i}`).join("\n")
|
|
const result = yield* (yield* Truncate.Service).output(content, {
|
|
maxLines: 1000,
|
|
maxBytes: 1024 * 1024,
|
|
})
|
|
expect(result.truncated).toBe(false)
|
|
}),
|
|
)
|
|
})
|
|
|
|
it.live("large single-line file truncates with byte message", () =>
|
|
Effect.gen(function* () {
|
|
const svc = yield* Truncate.Service
|
|
const fsys = yield* FSUtil.Service
|
|
const content = yield* fsys.readFileString(path.join(FIXTURES_DIR, "models-api.json"))
|
|
const result = yield* svc.output(content)
|
|
|
|
expect(result.truncated).toBe(true)
|
|
expect(result.content).toContain("bytes truncated...")
|
|
expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
|
|
}),
|
|
)
|
|
|
|
it.live("writes full output to file when truncated", () =>
|
|
Effect.gen(function* () {
|
|
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.content).toContain("The tool call succeeded but the output was truncated")
|
|
expect(result.content).toContain("Grep")
|
|
if (!result.truncated) throw new Error("expected truncated")
|
|
expect(result.outputPath).toBeDefined()
|
|
expect(result.outputPath).toContain("tool_")
|
|
|
|
const fsys = yield* FSUtil.Service
|
|
const written = yield* fsys.readFileString(result.outputPath!)
|
|
expect(written).toBe(lines)
|
|
}),
|
|
)
|
|
|
|
it.live("suggests Task tool when agent has task permission", () =>
|
|
Effect.gen(function* () {
|
|
const svc = yield* Truncate.Service
|
|
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.content).toContain("Grep")
|
|
expect(result.content).toContain("Task tool")
|
|
}),
|
|
)
|
|
|
|
it.live("omits Task tool hint when agent lacks task permission", () =>
|
|
Effect.gen(function* () {
|
|
const svc = yield* Truncate.Service
|
|
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.content).toContain("Grep")
|
|
expect(result.content).not.toContain("Task tool")
|
|
}),
|
|
)
|
|
|
|
it.live("does not write file when not truncated", () =>
|
|
Effect.gen(function* () {
|
|
const svc = yield* Truncate.Service
|
|
const content = "short content"
|
|
const result = yield* svc.output(content)
|
|
|
|
expect(result.truncated).toBe(false)
|
|
if (result.truncated) throw new Error("expected not truncated")
|
|
expect("outputPath" in result).toBe(false)
|
|
}),
|
|
)
|
|
|
|
test("loads truncate effect in a fresh process", async () => {
|
|
const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate.ts")], {
|
|
cwd: ROOT,
|
|
})
|
|
|
|
expect(out.code).toBe(0)
|
|
}, 20000)
|
|
})
|
|
|
|
describe("cleanup", () => {
|
|
const DAY_MS = 24 * 60 * 60 * 1000
|
|
|
|
it.live("deletes files older than 7 days and preserves recent files", () =>
|
|
Effect.gen(function* () {
|
|
const svc = yield* Truncate.Service
|
|
const fs = yield* FileSystem.FileSystem
|
|
|
|
yield* fs.makeDirectory(Truncate.DIR, { recursive: true })
|
|
|
|
const old = path.join(Truncate.DIR, Identifier.create("tool", "ascending", Date.now() - 10 * DAY_MS))
|
|
const recent = path.join(Truncate.DIR, Identifier.create("tool", "ascending", Date.now() - 3 * DAY_MS))
|
|
|
|
yield* writeFileStringScoped(old, "old content")
|
|
yield* writeFileStringScoped(recent, "recent content")
|
|
yield* svc.cleanup()
|
|
|
|
expect(yield* fs.exists(old)).toBe(false)
|
|
expect(yield* fs.exists(recent)).toBe(true)
|
|
}),
|
|
)
|
|
})
|
|
})
|