refactor(tool): Tool.Def.execute returns Effect, rename defineEffect → define (#21961)
This commit is contained in:
@@ -144,7 +144,7 @@ const filetime = Layer.succeed(
|
||||
read: () => Effect.void,
|
||||
get: () => Effect.succeed(undefined),
|
||||
assert: () => Effect.void,
|
||||
withLock: (_filepath, fn) => Effect.promise(fn),
|
||||
withLock: (_filepath, fn) => fn(),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -735,19 +735,12 @@ it.live(
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const { task } = yield* registry.named()
|
||||
const original = task.execute
|
||||
task.execute = async (_args, ctx) => {
|
||||
ready.resolve()
|
||||
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
|
||||
await new Promise<void>(() => {})
|
||||
return {
|
||||
title: "",
|
||||
metadata: {
|
||||
sessionId: SessionID.make("task"),
|
||||
model: ref,
|
||||
},
|
||||
output: "",
|
||||
}
|
||||
}
|
||||
task.execute = (_args, ctx) =>
|
||||
Effect.callback<never>((resume) => {
|
||||
ready.resolve()
|
||||
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
|
||||
return Effect.sync(() => aborted.resolve())
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original)))
|
||||
|
||||
const { prompt, chat } = yield* boot()
|
||||
@@ -1393,11 +1386,10 @@ function hangUntilAborted(tool: { execute: (...args: any[]) => any }) {
|
||||
const ready = defer<void>()
|
||||
const aborted = defer<void>()
|
||||
const original = tool.execute
|
||||
tool.execute = async (_args: any, ctx: any) => {
|
||||
tool.execute = (_args: any, ctx: any) => {
|
||||
ready.resolve()
|
||||
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
|
||||
await new Promise<void>(() => {})
|
||||
return { title: "", metadata: {}, output: "" }
|
||||
return Effect.callback<never>(() => {})
|
||||
}
|
||||
const restore = Effect.addFinalizer(() => Effect.sync(() => void (tool.execute = original)))
|
||||
return { ready, aborted, restore }
|
||||
|
||||
@@ -107,7 +107,7 @@ const filetime = Layer.succeed(
|
||||
read: () => Effect.void,
|
||||
get: () => Effect.succeed(undefined),
|
||||
assert: () => Effect.void,
|
||||
withLock: (_filepath, fn) => Effect.promise(fn),
|
||||
withLock: (_filepath, fn) => fn(),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -7,10 +7,11 @@ import { Instance } from "../../src/project/instance"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import { Format } from "../../src/format"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
|
||||
const runtime = ManagedRuntime.make(Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer))
|
||||
const runtime = ManagedRuntime.make(Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer))
|
||||
|
||||
const baseCtx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
@@ -42,22 +43,21 @@ type AskInput = {
|
||||
}
|
||||
|
||||
type ToolCtx = typeof baseCtx & {
|
||||
ask: (input: AskInput) => Promise<void>
|
||||
ask: (input: AskInput) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
const execute = async (params: { patchText: string }, ctx: ToolCtx) => {
|
||||
const info = await runtime.runPromise(ApplyPatchTool)
|
||||
const tool = await info.init()
|
||||
return tool.execute(params, ctx)
|
||||
return Effect.runPromise(tool.execute(params, ctx))
|
||||
}
|
||||
|
||||
const makeCtx = () => {
|
||||
const calls: AskInput[] = []
|
||||
const ctx: ToolCtx = {
|
||||
...baseCtx,
|
||||
ask: async (input) => {
|
||||
calls.push(input)
|
||||
},
|
||||
ask: (input) =>
|
||||
Effect.sync(() => { calls.push(input) }),
|
||||
}
|
||||
|
||||
return { ctx, calls }
|
||||
|
||||
@@ -30,7 +30,7 @@ const ctx = {
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
Shell.acceptable.reset()
|
||||
@@ -109,10 +109,11 @@ const each = (name: string, fn: (item: { label: string; shell: string }) => Prom
|
||||
|
||||
const capture = (requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">>, stop?: Error) => ({
|
||||
...ctx,
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
if (stop) throw stop
|
||||
},
|
||||
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
|
||||
Effect.sync(() => {
|
||||
requests.push(req)
|
||||
if (stop) throw stop
|
||||
}),
|
||||
})
|
||||
|
||||
const mustTruncate = (result: {
|
||||
@@ -131,13 +132,13 @@ describe("tool.bash", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const result = await bash.execute(
|
||||
const result = await Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: "echo test",
|
||||
description: "Echo test message",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
))
|
||||
expect(result.metadata.exit).toBe(0)
|
||||
expect(result.metadata.output).toContain("test")
|
||||
},
|
||||
@@ -153,13 +154,13 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
await Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: "echo hello",
|
||||
description: "Echo hello",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
))
|
||||
expect(requests.length).toBe(1)
|
||||
expect(requests[0].permission).toBe("bash")
|
||||
expect(requests[0].patterns).toContain("echo hello")
|
||||
@@ -174,13 +175,13 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
await Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: "echo foo && echo bar",
|
||||
description: "Echo twice",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
))
|
||||
expect(requests.length).toBe(1)
|
||||
expect(requests[0].permission).toBe("bash")
|
||||
expect(requests[0].patterns).toContain("echo foo")
|
||||
@@ -198,13 +199,13 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
await Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: "Write-Host foo; if ($?) { Write-Host bar }",
|
||||
description: "Check PowerShell conditional",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
))
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
expect(bashReq).toBeDefined()
|
||||
expect(bashReq!.patterns).toContain("Write-Host foo")
|
||||
@@ -226,13 +227,13 @@ describe("tool.bash permissions", () => {
|
||||
const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*"
|
||||
const want = process.platform === "win32" ? glob(path.join(process.env.WINDIR!, "*")) : "/etc/*"
|
||||
await expect(
|
||||
bash.execute(
|
||||
Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: `cat ${file}`,
|
||||
description: "Read wildcard path",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
)),
|
||||
).rejects.toThrow(err.message)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeDefined()
|
||||
@@ -257,13 +258,13 @@ describe("tool.bash permissions", () => {
|
||||
const bash = await initBash()
|
||||
const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
await Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: `echo $(cat "${file}")`,
|
||||
description: "Read nested bash file",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
))
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
expect(extDirReq).toBeDefined()
|
||||
@@ -289,13 +290,13 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
bash.execute(
|
||||
Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`,
|
||||
description: "Copy Windows ini",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
)),
|
||||
).rejects.toThrow(err.message)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeDefined()
|
||||
@@ -316,13 +317,13 @@ describe("tool.bash permissions", () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`
|
||||
await bash.execute(
|
||||
await Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: `Write-Output $(Get-Content ${file})`,
|
||||
description: "Read nested PowerShell file",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
))
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
expect(extDirReq).toBeDefined()
|
||||
@@ -347,13 +348,13 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
bash.execute(
|
||||
Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: 'Get-Content "C:../outside.txt"',
|
||||
description: "Read drive-relative file",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
)),
|
||||
).rejects.toThrow(err.message)
|
||||
expect(requests[0]?.permission).toBe("external_directory")
|
||||
if (requests[0]?.permission !== "external_directory") return
|
||||
@@ -375,13 +376,13 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
bash.execute(
|
||||
Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: 'Get-Content "$HOME/.ssh/config"',
|
||||
description: "Read home config",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
)),
|
||||
).rejects.toThrow(err.message)
|
||||
expect(requests[0]?.permission).toBe("external_directory")
|
||||
if (requests[0]?.permission !== "external_directory") return
|
||||
@@ -404,13 +405,13 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
bash.execute(
|
||||
Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: 'Get-Content "$PWD/../outside.txt"',
|
||||
description: "Read pwd-relative file",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
)),
|
||||
).rejects.toThrow(err.message)
|
||||
expect(requests[0]?.permission).toBe("external_directory")
|
||||
if (requests[0]?.permission !== "external_directory") return
|
||||
@@ -432,13 +433,13 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
bash.execute(
|
||||
Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: 'Get-Content "$PSHOME/outside.txt"',
|
||||
description: "Read pshome file",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
)),
|
||||
).rejects.toThrow(err.message)
|
||||
expect(requests[0]?.permission).toBe("external_directory")
|
||||
if (requests[0]?.permission !== "external_directory") return
|
||||
@@ -465,13 +466,13 @@ describe("tool.bash permissions", () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "")
|
||||
await expect(
|
||||
bash.execute(
|
||||
Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`,
|
||||
description: "Read Windows ini with missing env",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
)),
|
||||
).rejects.toThrow(err.message)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeDefined()
|
||||
@@ -495,13 +496,13 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
await Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: "Get-Content $env:WINDIR/win.ini",
|
||||
description: "Read Windows ini from env",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
))
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeDefined()
|
||||
expect(extDirReq!.patterns).toContain(
|
||||
@@ -524,13 +525,13 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
bash.execute(
|
||||
Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`,
|
||||
description: "Read Windows ini from FileSystem provider",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
)),
|
||||
).rejects.toThrow(err.message)
|
||||
expect(requests[0]?.permission).toBe("external_directory")
|
||||
if (requests[0]?.permission !== "external_directory") return
|
||||
@@ -554,13 +555,13 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
bash.execute(
|
||||
Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: "Get-Content ${env:WINDIR}/win.ini",
|
||||
description: "Read Windows ini from braced env",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
)),
|
||||
).rejects.toThrow(err.message)
|
||||
expect(requests[0]?.permission).toBe("external_directory")
|
||||
if (requests[0]?.permission !== "external_directory") return
|
||||
@@ -582,13 +583,13 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
await Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: "Set-Location C:/Windows",
|
||||
description: "Change location",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
))
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
expect(extDirReq).toBeDefined()
|
||||
@@ -611,13 +612,13 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
await Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: "Write-Output ('a' * 3)",
|
||||
description: "Write repeated text",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
))
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
expect(bashReq).toBeDefined()
|
||||
expect(bashReq!.patterns).not.toContain("a * 3")
|
||||
@@ -638,13 +639,13 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
bash.execute(
|
||||
Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: "cd ../",
|
||||
description: "Change to parent directory",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
)),
|
||||
).rejects.toThrow(err.message)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeDefined()
|
||||
@@ -661,14 +662,14 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
bash.execute(
|
||||
Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: "echo ok",
|
||||
workdir: os.tmpdir(),
|
||||
description: "Echo from temp dir",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
)),
|
||||
).rejects.toThrow(err.message)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeDefined()
|
||||
@@ -691,14 +692,14 @@ describe("tool.bash permissions", () => {
|
||||
for (const dir of forms(outerTmp.path)) {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
bash.execute(
|
||||
Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: "echo ok",
|
||||
workdir: dir,
|
||||
description: "Echo from external dir",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
)),
|
||||
).rejects.toThrow(err.message)
|
||||
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
@@ -724,14 +725,14 @@ describe("tool.bash permissions", () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const want = glob(path.join(os.tmpdir(), "*"))
|
||||
await expect(
|
||||
bash.execute(
|
||||
Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: "echo ok",
|
||||
workdir: "/tmp",
|
||||
description: "Echo from Git Bash tmp",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
)),
|
||||
).rejects.toThrow(err.message)
|
||||
expect(requests[0]).toMatchObject({
|
||||
permission: "external_directory",
|
||||
@@ -754,13 +755,13 @@ describe("tool.bash permissions", () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const want = glob(path.join(os.tmpdir(), "*"))
|
||||
await expect(
|
||||
bash.execute(
|
||||
Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: "cat /tmp/opencode-does-not-exist",
|
||||
description: "Read Git Bash tmp file",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
)),
|
||||
).rejects.toThrow(err.message)
|
||||
expect(requests[0]).toMatchObject({
|
||||
permission: "external_directory",
|
||||
@@ -789,13 +790,13 @@ describe("tool.bash permissions", () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const filepath = path.join(outerTmp.path, "outside.txt")
|
||||
await expect(
|
||||
bash.execute(
|
||||
Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: `cat ${filepath}`,
|
||||
description: "Read external file",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
)),
|
||||
).rejects.toThrow(err.message)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
const expected = glob(path.join(outerTmp.path, "*"))
|
||||
@@ -817,13 +818,13 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
await Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: `rm -rf ${path.join(tmp.path, "nested")}`,
|
||||
description: "Remove nested dir",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
))
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeUndefined()
|
||||
},
|
||||
@@ -837,13 +838,13 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
await Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: "git log --oneline -5",
|
||||
description: "Git log",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
))
|
||||
expect(requests.length).toBe(1)
|
||||
expect(requests[0].always.length).toBeGreaterThan(0)
|
||||
expect(requests[0].always.some((item) => item.endsWith("*"))).toBe(true)
|
||||
@@ -858,13 +859,13 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
await Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: "cd .",
|
||||
description: "Stay in current directory",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
))
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
expect(bashReq).toBeUndefined()
|
||||
},
|
||||
@@ -880,10 +881,10 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
bash.execute(
|
||||
Effect.runPromise(bash.execute(
|
||||
{ command: "echo test > output.txt", description: "Redirect test output" },
|
||||
capture(requests, err),
|
||||
),
|
||||
)),
|
||||
).rejects.toThrow(err.message)
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
expect(bashReq).toBeDefined()
|
||||
@@ -899,7 +900,7 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute({ command: "ls -la", description: "List" }, capture(requests))
|
||||
await Effect.runPromise(bash.execute({ command: "ls -la", description: "List" }, capture(requests)))
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
expect(bashReq).toBeDefined()
|
||||
expect(bashReq!.always[0]).toBe("ls *")
|
||||
@@ -916,7 +917,7 @@ describe("tool.bash abort", () => {
|
||||
const bash = await initBash()
|
||||
const controller = new AbortController()
|
||||
const collected: string[] = []
|
||||
const result = bash.execute(
|
||||
const res = await Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: `echo before && sleep 30`,
|
||||
description: "Long running command",
|
||||
@@ -932,8 +933,7 @@ describe("tool.bash abort", () => {
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
const res = await result
|
||||
))
|
||||
expect(res.output).toContain("before")
|
||||
expect(res.output).toContain("User aborted the command")
|
||||
expect(collected.length).toBeGreaterThan(0)
|
||||
@@ -946,14 +946,14 @@ describe("tool.bash abort", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const result = await bash.execute(
|
||||
const result = await Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: `echo started && sleep 60`,
|
||||
description: "Timeout test",
|
||||
timeout: 500,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
))
|
||||
expect(result.output).toContain("started")
|
||||
expect(result.output).toContain("bash tool terminated command after exceeding timeout")
|
||||
},
|
||||
@@ -965,13 +965,13 @@ describe("tool.bash abort", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const result = await bash.execute(
|
||||
const result = await Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: `echo stdout_msg && echo stderr_msg >&2`,
|
||||
description: "Stderr test",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
))
|
||||
expect(result.output).toContain("stdout_msg")
|
||||
expect(result.output).toContain("stderr_msg")
|
||||
expect(result.metadata.exit).toBe(0)
|
||||
@@ -984,13 +984,13 @@ describe("tool.bash abort", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const result = await bash.execute(
|
||||
const result = await Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: `exit 42`,
|
||||
description: "Non-zero exit",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
))
|
||||
expect(result.metadata.exit).toBe(42)
|
||||
},
|
||||
})
|
||||
@@ -1002,7 +1002,7 @@ describe("tool.bash abort", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const updates: string[] = []
|
||||
const result = await bash.execute(
|
||||
const result = await Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: `echo first && sleep 0.1 && echo second`,
|
||||
description: "Streaming test",
|
||||
@@ -1014,7 +1014,7 @@ describe("tool.bash abort", () => {
|
||||
if (output) updates.push(output)
|
||||
},
|
||||
},
|
||||
)
|
||||
))
|
||||
expect(result.output).toContain("first")
|
||||
expect(result.output).toContain("second")
|
||||
expect(updates.length).toBeGreaterThan(1)
|
||||
@@ -1030,13 +1030,13 @@ describe("tool.bash truncation", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const lineCount = Truncate.MAX_LINES + 500
|
||||
const result = await bash.execute(
|
||||
const result = await Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: fill("lines", lineCount),
|
||||
description: "Generate lines exceeding limit",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
))
|
||||
mustTruncate(result)
|
||||
expect(result.output).toContain("truncated")
|
||||
expect(result.output).toContain("The tool call succeeded but the output was truncated")
|
||||
@@ -1050,13 +1050,13 @@ describe("tool.bash truncation", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const byteCount = Truncate.MAX_BYTES + 10000
|
||||
const result = await bash.execute(
|
||||
const result = await Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: fill("bytes", byteCount),
|
||||
description: "Generate bytes exceeding limit",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
))
|
||||
mustTruncate(result)
|
||||
expect(result.output).toContain("truncated")
|
||||
expect(result.output).toContain("The tool call succeeded but the output was truncated")
|
||||
@@ -1069,13 +1069,13 @@ describe("tool.bash truncation", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const result = await bash.execute(
|
||||
const result = await Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: "echo hello",
|
||||
description: "Echo hello",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
))
|
||||
expect((result.metadata as { truncated?: boolean }).truncated).toBe(false)
|
||||
expect(result.output).toContain("hello")
|
||||
},
|
||||
@@ -1088,13 +1088,13 @@ describe("tool.bash truncation", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const lineCount = Truncate.MAX_LINES + 100
|
||||
const result = await bash.execute(
|
||||
const result = await Effect.runPromise(bash.execute(
|
||||
{
|
||||
command: fill("lines", lineCount),
|
||||
description: "Generate lines for file check",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
))
|
||||
mustTruncate(result)
|
||||
|
||||
const filepath = (result.metadata as { outputPath?: string }).outputPath
|
||||
|
||||
@@ -7,6 +7,10 @@ import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { FileTime } from "../../src/file/time"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import { Format } from "../../src/format"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { BusEvent } from "../../src/bus/bus-event"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
|
||||
const ctx = {
|
||||
@@ -17,7 +21,7 @@ const ctx = {
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -29,7 +33,9 @@ async function touch(file: string, time: number) {
|
||||
await fs.utimes(file, date, date)
|
||||
}
|
||||
|
||||
const runtime = ManagedRuntime.make(Layer.mergeAll(LSP.defaultLayer, FileTime.defaultLayer))
|
||||
const runtime = ManagedRuntime.make(
|
||||
Layer.mergeAll(LSP.defaultLayer, FileTime.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer),
|
||||
)
|
||||
|
||||
afterAll(async () => {
|
||||
await runtime.dispose()
|
||||
@@ -43,6 +49,12 @@ const resolve = () =>
|
||||
}),
|
||||
)
|
||||
|
||||
const readFileTime = (sessionID: SessionID, filepath: string) =>
|
||||
runtime.runPromise(FileTime.Service.use((ft) => ft.read(sessionID, filepath)))
|
||||
|
||||
const subscribeBus = <D extends BusEvent.Definition>(def: D, callback: () => unknown) =>
|
||||
runtime.runPromise(Bus.Service.use((bus) => bus.subscribeCallback(def, callback)))
|
||||
|
||||
describe("tool.edit", () => {
|
||||
describe("creating new files", () => {
|
||||
test("creates new file when oldString is empty", async () => {
|
||||
@@ -53,14 +65,14 @@ describe("tool.edit", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
const result = await edit.execute(
|
||||
const result = await Effect.runPromise(edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "new content",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
))
|
||||
|
||||
expect(result.metadata.diff).toContain("new content")
|
||||
|
||||
@@ -78,14 +90,14 @@ describe("tool.edit", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
await edit.execute(
|
||||
await Effect.runPromise(edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "nested file",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
))
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("nested file")
|
||||
@@ -100,22 +112,20 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { Bus } = await import("../../src/bus")
|
||||
const { File } = await import("../../src/file")
|
||||
const { FileWatcher } = await import("../../src/file/watcher")
|
||||
|
||||
const events: string[] = []
|
||||
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
|
||||
const unsubUpdated = await subscribeBus(FileWatcher.Event.Updated, () => events.push("updated"))
|
||||
|
||||
const edit = await resolve()
|
||||
await edit.execute(
|
||||
await Effect.runPromise(edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "content",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
))
|
||||
|
||||
expect(events).toContain("updated")
|
||||
unsubUpdated()
|
||||
@@ -133,17 +143,17 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
const result = await edit.execute(
|
||||
const result = await Effect.runPromise(edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "old content",
|
||||
newString: "new content",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
))
|
||||
|
||||
expect(result.output).toContain("Edit applied successfully")
|
||||
|
||||
@@ -160,18 +170,18 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
edit.execute(
|
||||
Effect.runPromise(edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "old",
|
||||
newString: "new",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)),
|
||||
).rejects.toThrow("not found")
|
||||
},
|
||||
})
|
||||
@@ -187,14 +197,14 @@ describe("tool.edit", () => {
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
edit.execute(
|
||||
Effect.runPromise(edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "same",
|
||||
newString: "same",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)),
|
||||
).rejects.toThrow("identical")
|
||||
},
|
||||
})
|
||||
@@ -208,18 +218,18 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
edit.execute(
|
||||
Effect.runPromise(edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "not in file",
|
||||
newString: "replacement",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)),
|
||||
).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
@@ -235,14 +245,14 @@ describe("tool.edit", () => {
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
edit.execute(
|
||||
Effect.runPromise(edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "content",
|
||||
newString: "modified",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)),
|
||||
).rejects.toThrow("You must read file")
|
||||
},
|
||||
})
|
||||
@@ -258,7 +268,7 @@ describe("tool.edit", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Read first
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
// Simulate external modification
|
||||
await fs.writeFile(filepath, "modified externally", "utf-8")
|
||||
@@ -267,14 +277,14 @@ describe("tool.edit", () => {
|
||||
// Try to edit with the new content
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
edit.execute(
|
||||
Effect.runPromise(edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "modified externally",
|
||||
newString: "edited",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)),
|
||||
).rejects.toThrow("modified since it was last read")
|
||||
},
|
||||
})
|
||||
@@ -288,10 +298,10 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
await edit.execute(
|
||||
await Effect.runPromise(edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "foo",
|
||||
@@ -299,7 +309,7 @@ describe("tool.edit", () => {
|
||||
replaceAll: true,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
))
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("qux bar qux baz qux")
|
||||
@@ -315,23 +325,22 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const { Bus } = await import("../../src/bus")
|
||||
const { FileWatcher } = await import("../../src/file/watcher")
|
||||
|
||||
const events: string[] = []
|
||||
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
|
||||
const unsubUpdated = await subscribeBus(FileWatcher.Event.Updated, () => events.push("updated"))
|
||||
|
||||
const edit = await resolve()
|
||||
await edit.execute(
|
||||
await Effect.runPromise(edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "original",
|
||||
newString: "modified",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
))
|
||||
|
||||
expect(events).toContain("updated")
|
||||
unsubUpdated()
|
||||
@@ -349,17 +358,17 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
await edit.execute(
|
||||
await Effect.runPromise(edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "line2",
|
||||
newString: "new line 2\nextra line",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
))
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("line1\nnew line 2\nextra line\nline3")
|
||||
@@ -375,17 +384,17 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
await edit.execute(
|
||||
await Effect.runPromise(edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "old",
|
||||
newString: "new",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
))
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("line1\r\nnew\r\nline3")
|
||||
@@ -403,14 +412,14 @@ describe("tool.edit", () => {
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
edit.execute(
|
||||
Effect.runPromise(edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)),
|
||||
).rejects.toThrow("identical")
|
||||
},
|
||||
})
|
||||
@@ -424,18 +433,18 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, dirpath)
|
||||
await readFileTime(ctx.sessionID, dirpath)
|
||||
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
edit.execute(
|
||||
Effect.runPromise(edit.execute(
|
||||
{
|
||||
filePath: dirpath,
|
||||
oldString: "old",
|
||||
newString: "new",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)),
|
||||
).rejects.toThrow("directory")
|
||||
},
|
||||
})
|
||||
@@ -449,17 +458,17 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
const result = await edit.execute(
|
||||
const result = await Effect.runPromise(edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "line2",
|
||||
newString: "new line a\nnew line b",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
))
|
||||
|
||||
expect(result.metadata.filediff).toBeDefined()
|
||||
expect(result.metadata.filediff.file).toBe(filepath)
|
||||
@@ -520,8 +529,8 @@ describe("tool.edit", () => {
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
const filePath = path.join(tmp.path, "test.txt")
|
||||
await FileTime.read(ctx.sessionID, filePath)
|
||||
await edit.execute(
|
||||
await readFileTime(ctx.sessionID, filePath)
|
||||
await Effect.runPromise(edit.execute(
|
||||
{
|
||||
filePath,
|
||||
oldString: input.oldString,
|
||||
@@ -529,7 +538,7 @@ describe("tool.edit", () => {
|
||||
replaceAll: input.replaceAll,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
))
|
||||
return await Bun.file(filePath).text()
|
||||
},
|
||||
})
|
||||
@@ -661,31 +670,31 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
|
||||
// Two concurrent edits
|
||||
const promise1 = edit.execute(
|
||||
const promise1 = Effect.runPromise(edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "0",
|
||||
newString: "1",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
))
|
||||
|
||||
// Need to read again since FileTime tracks per-session
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const promise2 = edit.execute(
|
||||
const promise2 = Effect.runPromise(edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "0",
|
||||
newString: "2",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
))
|
||||
|
||||
// Both should complete without error (though one might fail due to content mismatch)
|
||||
const results = await Promise.allSettled([promise1, promise2])
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import type { Tool } from "../../src/tool/tool"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { assertExternalDirectory } from "../../src/tool/external-directory"
|
||||
@@ -21,15 +22,18 @@ const baseCtx: Omit<Tool.Context, "ask"> = {
|
||||
const glob = (p: string) =>
|
||||
process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
|
||||
|
||||
function makeCtx() {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: (req) => Effect.sync(() => { requests.push(req) }),
|
||||
}
|
||||
return { requests, ctx }
|
||||
}
|
||||
|
||||
describe("tool.assertExternalDirectory", () => {
|
||||
test("no-ops for empty target", async () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
const { requests, ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: "/tmp",
|
||||
@@ -42,13 +46,7 @@ describe("tool.assertExternalDirectory", () => {
|
||||
})
|
||||
|
||||
test("no-ops for paths inside Instance.directory", async () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
const { requests, ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: "/tmp/project",
|
||||
@@ -61,13 +59,7 @@ describe("tool.assertExternalDirectory", () => {
|
||||
})
|
||||
|
||||
test("asks with a single canonical glob", async () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
const { requests, ctx } = makeCtx()
|
||||
|
||||
const directory = "/tmp/project"
|
||||
const target = "/tmp/outside/file.txt"
|
||||
@@ -87,13 +79,7 @@ describe("tool.assertExternalDirectory", () => {
|
||||
})
|
||||
|
||||
test("uses target directory when kind=directory", async () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
const { requests, ctx } = makeCtx()
|
||||
|
||||
const directory = "/tmp/project"
|
||||
const target = "/tmp/outside"
|
||||
@@ -113,13 +99,7 @@ describe("tool.assertExternalDirectory", () => {
|
||||
})
|
||||
|
||||
test("skips prompting when bypass=true", async () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
const { requests, ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: "/tmp/project",
|
||||
@@ -133,13 +113,7 @@ describe("tool.assertExternalDirectory", () => {
|
||||
|
||||
if (process.platform === "win32") {
|
||||
test("normalizes Windows path variants to one glob", async () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
const { requests, ctx } = makeCtx()
|
||||
|
||||
await using outerTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
@@ -169,13 +143,7 @@ describe("tool.assertExternalDirectory", () => {
|
||||
})
|
||||
|
||||
test("uses drive root glob for root files", async () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
const { requests, ctx } = makeCtx()
|
||||
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const root = path.parse(tmp.path).root
|
||||
|
||||
@@ -21,7 +21,7 @@ const ctx = {
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
const projectRoot = path.join(__dirname, "../..")
|
||||
@@ -32,14 +32,14 @@ describe("tool.grep", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const grep = await initGrep()
|
||||
const result = await grep.execute(
|
||||
const result = await Effect.runPromise(grep.execute(
|
||||
{
|
||||
pattern: "export",
|
||||
path: path.join(projectRoot, "src/tool"),
|
||||
include: "*.ts",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
))
|
||||
expect(result.metadata.matches).toBeGreaterThan(0)
|
||||
expect(result.output).toContain("Found")
|
||||
},
|
||||
@@ -56,13 +56,13 @@ describe("tool.grep", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const grep = await initGrep()
|
||||
const result = await grep.execute(
|
||||
const result = await Effect.runPromise(grep.execute(
|
||||
{
|
||||
pattern: "xyznonexistentpatternxyz123",
|
||||
path: tmp.path,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
))
|
||||
expect(result.metadata.matches).toBe(0)
|
||||
expect(result.output).toBe("No files found")
|
||||
},
|
||||
@@ -81,13 +81,13 @@ describe("tool.grep", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const grep = await initGrep()
|
||||
const result = await grep.execute(
|
||||
const result = await Effect.runPromise(grep.execute(
|
||||
{
|
||||
pattern: "line",
|
||||
path: tmp.path,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
))
|
||||
expect(result.metadata.matches).toBeGreaterThan(0)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ const ctx = {
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
@@ -49,7 +49,7 @@ describe("tool.question", () => {
|
||||
},
|
||||
]
|
||||
|
||||
const fiber = yield* Effect.promise(() => tool.execute({ questions }, ctx)).pipe(Effect.forkScoped)
|
||||
const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped)
|
||||
const item = yield* pending(question)
|
||||
yield* question.reply({ requestID: item.id, answers: [["Red"]] })
|
||||
|
||||
@@ -73,7 +73,7 @@ describe("tool.question", () => {
|
||||
},
|
||||
]
|
||||
|
||||
const fiber = yield* Effect.promise(() => tool.execute({ questions }, ctx)).pipe(Effect.forkScoped)
|
||||
const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped)
|
||||
const item = yield* pending(question)
|
||||
yield* question.reply({ requestID: item.id, answers: [["Dog"]] })
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ const ctx = {
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
const it = testEffect(
|
||||
@@ -54,7 +54,7 @@ const run = Effect.fn("ReadToolTest.run")(function* (
|
||||
next: Tool.Context = ctx,
|
||||
) {
|
||||
const tool = yield* init()
|
||||
return yield* Effect.promise(() => tool.execute(args, next))
|
||||
return yield* tool.execute(args, next)
|
||||
})
|
||||
|
||||
const exec = Effect.fn("ReadToolTest.exec")(function* (
|
||||
@@ -95,9 +95,8 @@ const asks = () => {
|
||||
items,
|
||||
next: {
|
||||
...ctx,
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
items.push(req)
|
||||
},
|
||||
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
|
||||
Effect.sync(() => { items.push(req) }),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -226,17 +225,18 @@ describe("tool.read env file permissions", () => {
|
||||
let asked = false
|
||||
const next = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
for (const pattern of req.patterns) {
|
||||
const rule = Permission.evaluate(req.permission, pattern, info.permission)
|
||||
if (rule.action === "ask" && req.permission === "read") {
|
||||
asked = true
|
||||
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
|
||||
Effect.sync(() => {
|
||||
for (const pattern of req.patterns) {
|
||||
const rule = Permission.evaluate(req.permission, pattern, info.permission)
|
||||
if (rule.action === "ask" && req.permission === "read") {
|
||||
asked = true
|
||||
}
|
||||
if (rule.action === "deny") {
|
||||
throw new Permission.DeniedError({ ruleset: info.permission })
|
||||
}
|
||||
}
|
||||
if (rule.action === "deny") {
|
||||
throw new Permission.DeniedError({ ruleset: info.permission })
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
yield* run({ filePath: path.join(dir, filename) }, next)
|
||||
|
||||
@@ -156,12 +156,11 @@ Use this skill.
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
ask: (req) =>
|
||||
Effect.sync(() => { requests.push(req) }),
|
||||
}
|
||||
|
||||
const result = await tool.execute({ name: "tool-skill" }, ctx)
|
||||
const result = await runtime.runPromise(tool.execute({ name: "tool-skill" }, ctx))
|
||||
const dir = path.join(tmp.path, ".opencode", "skill", "tool-skill")
|
||||
const file = path.resolve(dir, "scripts", "demo.txt")
|
||||
|
||||
|
||||
@@ -194,8 +194,7 @@ describe("tool.task", () => {
|
||||
let seen: SessionPrompt.PromptInput | undefined
|
||||
const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
def.execute(
|
||||
const result = yield* def.execute(
|
||||
{
|
||||
description: "inspect bug",
|
||||
prompt: "look into the cache key path",
|
||||
@@ -210,10 +209,9 @@ describe("tool.task", () => {
|
||||
extra: { promptOps },
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: async () => {},
|
||||
ask: () => Effect.void,
|
||||
},
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
const kids = yield* sessions.children(chat.id)
|
||||
expect(kids).toHaveLength(1)
|
||||
@@ -235,8 +233,7 @@ describe("tool.task", () => {
|
||||
const promptOps = stubOps()
|
||||
|
||||
const exec = (extra?: Record<string, any>) =>
|
||||
Effect.promise(() =>
|
||||
def.execute(
|
||||
def.execute(
|
||||
{
|
||||
description: "inspect bug",
|
||||
prompt: "look into the cache key path",
|
||||
@@ -250,12 +247,10 @@ describe("tool.task", () => {
|
||||
extra: { promptOps, ...extra },
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: async (input) => {
|
||||
calls.push(input)
|
||||
},
|
||||
ask: (input) =>
|
||||
Effect.sync(() => { calls.push(input) }),
|
||||
},
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
yield* exec()
|
||||
yield* exec({ bypassAgentCheck: true })
|
||||
@@ -284,8 +279,7 @@ describe("tool.task", () => {
|
||||
let seen: SessionPrompt.PromptInput | undefined
|
||||
const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
def.execute(
|
||||
const result = yield* def.execute(
|
||||
{
|
||||
description: "inspect bug",
|
||||
prompt: "look into the cache key path",
|
||||
@@ -300,10 +294,9 @@ describe("tool.task", () => {
|
||||
extra: { promptOps },
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: async () => {},
|
||||
ask: () => Effect.void,
|
||||
},
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
const kids = yield* sessions.children(chat.id)
|
||||
expect(kids).toHaveLength(1)
|
||||
@@ -326,8 +319,7 @@ describe("tool.task", () => {
|
||||
let seen: SessionPrompt.PromptInput | undefined
|
||||
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
def.execute(
|
||||
const result = yield* def.execute(
|
||||
{
|
||||
description: "inspect bug",
|
||||
prompt: "look into the cache key path",
|
||||
@@ -341,10 +333,9 @@ describe("tool.task", () => {
|
||||
extra: { promptOps },
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: async () => {},
|
||||
ask: () => Effect.void,
|
||||
},
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
const child = yield* sessions.get(result.metadata.sessionId)
|
||||
expect(child.parentID).toBe(chat.id)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import z from "zod"
|
||||
import { Tool } from "../../src/tool/tool"
|
||||
|
||||
@@ -8,9 +9,9 @@ function makeTool(id: string, executeFn?: () => void) {
|
||||
return {
|
||||
description: "test tool",
|
||||
parameters: params,
|
||||
async execute() {
|
||||
execute() {
|
||||
executeFn?.()
|
||||
return { title: "test", output: "ok", metadata: {} }
|
||||
return Effect.succeed({ title: "test", output: "ok", metadata: {} })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -20,29 +21,31 @@ describe("Tool.define", () => {
|
||||
const original = makeTool("test")
|
||||
const originalExecute = original.execute
|
||||
|
||||
const tool = Tool.define("test-tool", original)
|
||||
const info = await Effect.runPromise(Tool.define("test-tool", Effect.succeed(original)))
|
||||
|
||||
await tool.init()
|
||||
await tool.init()
|
||||
await tool.init()
|
||||
await info.init()
|
||||
await info.init()
|
||||
await info.init()
|
||||
|
||||
expect(original.execute).toBe(originalExecute)
|
||||
})
|
||||
|
||||
test("function-defined tool returns fresh objects and is unaffected", async () => {
|
||||
const tool = Tool.define("test-fn-tool", () => Promise.resolve(makeTool("test")))
|
||||
const info = await Effect.runPromise(
|
||||
Tool.define("test-fn-tool", Effect.succeed(() => Promise.resolve(makeTool("test")))),
|
||||
)
|
||||
|
||||
const first = await tool.init()
|
||||
const second = await tool.init()
|
||||
const first = await info.init()
|
||||
const second = await info.init()
|
||||
|
||||
expect(first).not.toBe(second)
|
||||
})
|
||||
|
||||
test("object-defined tool returns distinct objects per init() call", async () => {
|
||||
const tool = Tool.define("test-copy", makeTool("test"))
|
||||
const info = await Effect.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test"))))
|
||||
|
||||
const first = await tool.init()
|
||||
const second = await tool.init()
|
||||
const first = await info.init()
|
||||
const second = await info.init()
|
||||
|
||||
expect(first).not.toBe(second)
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ const ctx = {
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
async function withFetch(fetch: (req: Request) => Response | Promise<Response>, fn: (url: URL) => Promise<void>) {
|
||||
@@ -42,10 +42,10 @@ describe("tool.webfetch", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const webfetch = await initTool()
|
||||
const result = await webfetch.execute(
|
||||
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.attachments).toBeDefined()
|
||||
expect(result.attachments?.length).toBe(1)
|
||||
@@ -74,7 +74,7 @@ describe("tool.webfetch", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const webfetch = await initTool()
|
||||
const result = await webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx)
|
||||
const result = await Effect.runPromise(webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx))
|
||||
expect(result.output).toContain("<svg")
|
||||
expect(result.attachments).toBeUndefined()
|
||||
},
|
||||
@@ -95,7 +95,7 @@ describe("tool.webfetch", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const webfetch = await initTool()
|
||||
const result = await webfetch.execute({ url: new URL("/file.txt", url).toString(), format: "text" }, ctx)
|
||||
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.attachments).toBeUndefined()
|
||||
},
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Instance } from "../../src/project/instance"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import { FileTime } from "../../src/file/time"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Format } from "../../src/format"
|
||||
import { Tool } from "../../src/tool/tool"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
@@ -21,7 +23,7 @@ const ctx = {
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -29,7 +31,7 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, FileTime.defaultLayer, CrossSpawnSpawner.defaultLayer),
|
||||
Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, FileTime.defaultLayer, Bus.layer, Format.defaultLayer, CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
|
||||
const init = Effect.fn("WriteToolTest.init")(function* () {
|
||||
@@ -42,7 +44,7 @@ const run = Effect.fn("WriteToolTest.run")(function* (
|
||||
next: Tool.Context = ctx,
|
||||
) {
|
||||
const tool = yield* init()
|
||||
return yield* Effect.promise(() => tool.execute(args, next))
|
||||
return yield* tool.execute(args, next)
|
||||
})
|
||||
|
||||
const markRead = Effect.fn("WriteToolTest.markRead")(function* (sessionID: string, filepath: string) {
|
||||
|
||||
Reference in New Issue
Block a user