Merge remote-tracking branch 'origin/dev' into fix/v2-session-wake-retry-dev
This commit is contained in:
commit
6ec6593e9e
92 changed files with 4008 additions and 2574 deletions
|
|
@ -3,9 +3,13 @@ import { Tool } from "@opencode-ai/core/public"
|
|||
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { Effect, Exit, Layer, Schema, Scope } from "effect"
|
||||
import { Tools } from "@opencode-ai/core/tool/tools"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const permission = Layer.mock(PermissionV2.Service, {
|
||||
|
|
@ -20,11 +24,13 @@ const registry = ToolRegistry.layer.pipe(
|
|||
const it = testEffect(Layer.mergeAll(applications, registry))
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_application_tool")
|
||||
const agent = AgentV2.ID.make("build")
|
||||
const assistantMessageID = SessionMessage.ID.make("msg_application_tool")
|
||||
const contextual = (contexts: Tool.Context[]) =>
|
||||
Tool.make({
|
||||
description: "Read application context",
|
||||
parameters: Schema.Struct({ query: Schema.String }),
|
||||
success: Schema.Struct({ answer: Schema.String }),
|
||||
input: Schema.Struct({ query: Schema.String }),
|
||||
output: Schema.Struct({ answer: Schema.String }),
|
||||
execute: ({ query }, context) =>
|
||||
Effect.sync(() => {
|
||||
contexts.push(context)
|
||||
|
|
@ -37,23 +43,69 @@ const contextual = (contexts: Tool.Context[]) =>
|
|||
})
|
||||
|
||||
describe("ApplicationTools", () => {
|
||||
it.effect("keeps the Core carrier opaque and executes its single handler", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const contexts: Tool.Context[] = []
|
||||
const tool = contextual(contexts)
|
||||
expect(Object.keys(tool)).toEqual([])
|
||||
|
||||
yield* applications.register({ opaque: tool })
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-opaque", name: "opaque", input: { query: "once" } },
|
||||
}),
|
||||
).toEqual({
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "ONCE" },
|
||||
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "result.png" },
|
||||
],
|
||||
})
|
||||
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-opaque" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("exposes narrow scoped Location registration and validates names", () =>
|
||||
Effect.gen(function* () {
|
||||
const tools: Tools.Interface = yield* Tools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
|
||||
yield* tools.register({ location_tool: contextual([]) }).pipe(Scope.provide(scope))
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["location_tool"])
|
||||
expect(yield* Effect.flip(tools.register({ "invalid name": contextual([]) }))).toBeInstanceOf(
|
||||
Tool.RegistrationError,
|
||||
)
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* toolDefinitions(registry)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters an application tool by its name without adding execution authorization", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const contexts: Tool.Context[] = []
|
||||
yield* applications.attach({ application_context: contextual(contexts) })
|
||||
yield* applications.register({ application_context: contextual(contexts) })
|
||||
|
||||
expect(yield* registry.definitions([{ action: "application_context", resource: "*", effect: "deny" }])).toEqual(
|
||||
[],
|
||||
)
|
||||
expect(
|
||||
yield* registry.settle({
|
||||
yield* toolDefinitions(registry, [{ action: "application_context", resource: "*", effect: "deny" }]),
|
||||
).toEqual([])
|
||||
expect(
|
||||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-denied", name: "application_context", input: { query: "hello" } },
|
||||
}),
|
||||
).toMatchObject({ result: { type: "content" } })
|
||||
expect(contexts).toEqual([{ sessionID, id: "call-denied", name: "application_context" }])
|
||||
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-denied" }])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -63,14 +115,16 @@ describe("ApplicationTools", () => {
|
|||
const registry = yield* ToolRegistry.Service
|
||||
const contexts: Tool.Context[] = []
|
||||
|
||||
yield* applications.attach({ application_context: contextual(contexts) })
|
||||
yield* applications.register({ application_context: contextual(contexts) })
|
||||
|
||||
expect(yield* registry.definitions()).toMatchObject([
|
||||
expect(yield* toolDefinitions(registry)).toMatchObject([
|
||||
{ name: "application_context", description: "Read application context" },
|
||||
])
|
||||
expect(
|
||||
yield* registry.settle({
|
||||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-context", name: "application_context", input: { query: "hello" } },
|
||||
}),
|
||||
).toEqual({
|
||||
|
|
@ -89,21 +143,21 @@ describe("ApplicationTools", () => {
|
|||
],
|
||||
},
|
||||
})
|
||||
expect(contexts).toEqual([{ sessionID, id: "call-context", name: "application_context" }])
|
||||
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-context" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes an application tool when its attachment scope closes", () =>
|
||||
it.effect("removes an application tool when its registration scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
|
||||
yield* applications.attach({ temporary: contextual([]) }).pipe(Scope.provide(scope))
|
||||
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["temporary"])
|
||||
yield* applications.register({ temporary: contextual([]) }).pipe(Scope.provide(scope))
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["temporary"])
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* registry.definitions()).toEqual([])
|
||||
expect(yield* toolDefinitions(registry)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -111,70 +165,99 @@ describe("ApplicationTools", () => {
|
|||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const attachmentScope = yield* Scope.make()
|
||||
yield* applications.attach({ contextual: contextual([]) }).pipe(Scope.provide(attachmentScope))
|
||||
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["contextual"])
|
||||
const registrationScope = yield* Scope.make()
|
||||
yield* applications.register({ contextual: contextual([]) }).pipe(Scope.provide(registrationScope))
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["contextual"])
|
||||
|
||||
yield* Scope.close(attachmentScope, Exit.void)
|
||||
yield* Scope.close(registrationScope, Exit.void)
|
||||
expect(
|
||||
yield* registry.settle({
|
||||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-removed", name: "contextual", input: { query: "hello" } },
|
||||
}),
|
||||
).toEqual({ result: { type: "error", value: "Unknown tool: contextual" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not leak an attachment into an already closed scope", () =>
|
||||
it.effect("does not leak a registration into an already closed scope", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
|
||||
yield* applications.attach({ closed: contextual([]) }).pipe(Scope.provide(scope))
|
||||
yield* applications.register({ closed: contextual([]) }).pipe(Scope.provide(scope))
|
||||
|
||||
expect(yield* registry.definitions()).toEqual([])
|
||||
expect(yield* toolDefinitions(registry)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("captures the attached record before later State rebuilds", () =>
|
||||
it.effect("preserves an interrupted application registration until its scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const attached = { stable: contextual([]) }
|
||||
yield* applications.attach(attached)
|
||||
Object.assign(attached, { late: contextual([]) })
|
||||
const scope = yield* Scope.make()
|
||||
const registered = yield* Deferred.make<void>()
|
||||
const fiber = yield* applications
|
||||
.register({ interrupted: contextual([]) })
|
||||
.pipe(
|
||||
Effect.andThen(Deferred.succeed(registered, undefined)),
|
||||
Effect.andThen(Effect.never),
|
||||
Scope.provide(scope),
|
||||
Effect.forkChild,
|
||||
)
|
||||
yield* Deferred.await(registered)
|
||||
yield* Fiber.interrupt(fiber)
|
||||
|
||||
yield* Effect.scoped(applications.attach({ temporary: contextual([]) }))
|
||||
|
||||
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["stable"])
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["interrupted"])
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* toolDefinitions(registry)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("settles with the current same-name application tool and restores earlier attachments", () =>
|
||||
it.effect("captures the registered record before later State rebuilds", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const registered = { stable: contextual([]) }
|
||||
yield* applications.register(registered)
|
||||
Object.assign(registered, { late: contextual([]) })
|
||||
|
||||
yield* Effect.scoped(applications.register({ temporary: contextual([]) }))
|
||||
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["stable"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("settles with the current same-name application tool and restores earlier registrations", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const firstContexts: Tool.Context[] = []
|
||||
const secondContexts: Tool.Context[] = []
|
||||
const scope = yield* Scope.make()
|
||||
yield* applications.attach({ contextual: contextual(firstContexts) })
|
||||
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["contextual"])
|
||||
yield* applications.attach({ contextual: contextual(secondContexts) }).pipe(Scope.provide(scope))
|
||||
yield* applications.register({ contextual: contextual(firstContexts) })
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["contextual"])
|
||||
yield* applications.register({ contextual: contextual(secondContexts) }).pipe(Scope.provide(scope))
|
||||
|
||||
yield* registry.settle({
|
||||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-second", name: "contextual", input: { query: "second" } },
|
||||
})
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* registry.settle({
|
||||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-first", name: "contextual", input: { query: "first" } },
|
||||
})
|
||||
|
||||
expect(secondContexts).toEqual([{ sessionID, id: "call-second", name: "contextual" }])
|
||||
expect(firstContexts).toEqual([{ sessionID, id: "call-first", name: "contextual" }])
|
||||
expect(secondContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-second" }])
|
||||
expect(firstContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-first" }])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -182,32 +265,26 @@ describe("ApplicationTools", () => {
|
|||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const transform = yield* registry.transform()
|
||||
const locationContexts: Tool.Context[] = []
|
||||
const applicationContexts: Tool.Context[] = []
|
||||
const location = contextual(locationContexts)
|
||||
yield* transform((editor) =>
|
||||
editor.set("shared", {
|
||||
tool: location.definition,
|
||||
permission: { action: "question", resource: "*" },
|
||||
execute: ({ parameters, sessionID, call }) =>
|
||||
location.execute(parameters, { sessionID, id: call.id, name: call.name }),
|
||||
}),
|
||||
)
|
||||
yield* applications.attach({ shared: contextual(applicationContexts) })
|
||||
yield* registry.register({ shared: location })
|
||||
yield* applications.register({ shared: contextual(applicationContexts) })
|
||||
|
||||
expect(
|
||||
(yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).map(
|
||||
(yield* toolDefinitions(registry, [{ action: "shared", resource: "*", effect: "deny" }])).map(
|
||||
(definition) => definition.name,
|
||||
),
|
||||
).toEqual([])
|
||||
expect(
|
||||
yield* registry.settle({
|
||||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-shared", name: "shared", input: { query: "location" } },
|
||||
}),
|
||||
).toMatchObject({ result: { type: "content" } })
|
||||
expect(locationContexts).toEqual([{ sessionID, id: "call-shared", name: "shared" }])
|
||||
expect(locationContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-shared" }])
|
||||
expect(applicationContexts).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
153
packages/core/test/filesystem/search.test.ts
Normal file
153
packages/core/test/filesystem/search.test.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Fff } from "#fff"
|
||||
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Search.defaultLayer)
|
||||
|
||||
const tmpdir = (init?: (dir: string) => Effect.Effect<void>) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(async () => fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-test-")))),
|
||||
(dir) =>
|
||||
Effect.promise(() => fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })).pipe(
|
||||
Effect.ignore,
|
||||
),
|
||||
).pipe(Effect.tap((dir) => init?.(dir) ?? Effect.void))
|
||||
|
||||
const write = (file: string, data: string) => Effect.promise(() => Bun.write(file, data))
|
||||
|
||||
describe("file.search", () => {
|
||||
it.live("uses fff for Bun-backed grep", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(Fff.available()).toBe(true)
|
||||
const dir = yield* tmpdir()
|
||||
yield* write(path.join(dir, "src", "match.ts"), "const needle = 1\n")
|
||||
|
||||
const search = yield* Search.Service
|
||||
const result = yield* search.search({ cwd: dir, pattern: "needle", limit: 10 })
|
||||
|
||||
expect(result.engine).toBe("fff")
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]?.path.text).toBe("src/match.ts")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps fuzzy file abbreviation matches", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(Fff.available()).toBe(true)
|
||||
const dir = yield* tmpdir()
|
||||
yield* write(path.join(dir, "README.md"), "hello\n")
|
||||
|
||||
const search = yield* Search.Service
|
||||
const results = yield* search.file({ cwd: dir, query: "rdme", limit: 10 })
|
||||
|
||||
expect(results).toContain("README.md")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps empty file query candidates", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(Fff.available()).toBe(true)
|
||||
const dir = yield* tmpdir()
|
||||
yield* write(path.join(dir, "README.md"), "hello\n")
|
||||
yield* write(path.join(dir, "src", "main.ts"), "export const main = true\n")
|
||||
|
||||
const search = yield* Search.Service
|
||||
const results = yield* search.file({ cwd: dir, query: "", limit: 10, kind: "all" })
|
||||
|
||||
expect(results).toContain("README.md")
|
||||
expect(results).toContain("src/")
|
||||
expect(results).not.toContain("")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps paging grep results without an explicit limit", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(Fff.available()).toBe(true)
|
||||
const dir = yield* tmpdir()
|
||||
yield* write(path.join(dir, "matches.txt"), Array.from({ length: 150 }, (_, idx) => `needle ${idx}\n`).join(""))
|
||||
|
||||
const search = yield* Search.Service
|
||||
const result = yield* search.search({ cwd: dir, pattern: "needle" })
|
||||
|
||||
expect(result.items).toHaveLength(150)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("uses byte ranges for UTF-8 grep submatches", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(Fff.available()).toBe(true)
|
||||
const dir = yield* tmpdir()
|
||||
yield* write(path.join(dir, "unicode.txt"), "éneedle\n")
|
||||
|
||||
const search = yield* Search.Service
|
||||
const result = yield* search.search({ cwd: dir, pattern: "needle", limit: 10 })
|
||||
|
||||
expect(result.items[0]?.submatches[0]?.match.text).toBe("needle")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("post-filters fff grep include matches", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(Fff.available()).toBe(true)
|
||||
const dir = yield* tmpdir()
|
||||
yield* write(path.join(dir, "src", "match.ts"), "needle\n")
|
||||
yield* write(path.join(dir, "src", "match.txt"), "needle\n")
|
||||
|
||||
const search = yield* Search.Service
|
||||
const result = yield* search.search({ cwd: dir, pattern: "needle", glob: ["*.ts"], limit: 10 })
|
||||
|
||||
expect(result.engine).toBe("fff")
|
||||
expect(result.items.map((entry) => entry.path.text)).toEqual(["src/match.ts"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps fff grep include no-match results", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(Fff.available()).toBe(true)
|
||||
const dir = yield* tmpdir()
|
||||
yield* write(path.join(dir, "src", "match.ts"), "needle\n")
|
||||
|
||||
const search = yield* Search.Service
|
||||
const result = yield* search.search({ cwd: dir, pattern: "missing", glob: ["*.ts"], limit: 10 })
|
||||
|
||||
expect(result.engine).toBe("fff")
|
||||
expect(result.items).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("post-filters fff glob matches", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(Fff.available()).toBe(true)
|
||||
const dir = yield* tmpdir()
|
||||
yield* write(path.join(dir, "src", "match.ts"), "export const value = 1\n")
|
||||
yield* write(path.join(dir, "src", "match.txt"), "hello\n")
|
||||
|
||||
const search = yield* Search.Service
|
||||
const result = yield* search.glob({ cwd: dir, pattern: "**/*.ts", limit: 10 })
|
||||
|
||||
expect(result.files).toEqual([path.join(dir, "src", "match.ts")])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("tracks an opened file against its originating query", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(Fff.available()).toBe(true)
|
||||
const dir = yield* tmpdir()
|
||||
yield* write(path.join(dir, "alpha-target-one.ts"), "export const one = 1\n")
|
||||
yield* write(path.join(dir, "alpha-target-two.ts"), "export const two = 2\n")
|
||||
|
||||
const search = yield* Search.Service
|
||||
const results = yield* search.file({ cwd: dir, query: "alpha target two", limit: 10 })
|
||||
expect(results).toContain("alpha-target-two.ts")
|
||||
|
||||
// open() records the query->file association in fff's history db via the
|
||||
// live picker. It must resolve a remembered file and run without error.
|
||||
yield* search.open({ cwd: dir, file: "alpha-target-two.ts" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
20
packages/core/test/lib/tool.ts
Normal file
20
packages/core/test/lib/tool.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export const toolIdentity = {
|
||||
agent: AgentV2.ID.make("build"),
|
||||
assistantMessageID: SessionMessage.ID.make("msg_tool_test"),
|
||||
}
|
||||
|
||||
export const toolDefinitions = (
|
||||
registry: ToolRegistry.Interface,
|
||||
permissions?: Parameters<typeof registry.materialize>[0],
|
||||
) => registry.materialize(permissions).pipe(Effect.map((materialized) => materialized.definitions))
|
||||
|
||||
export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) =>
|
||||
registry.materialize().pipe(Effect.flatMap((materialized) => materialized.settle(input)))
|
||||
|
||||
export const executeTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) =>
|
||||
settleTool(registry, input).pipe(Effect.map((settlement) => settlement.result))
|
||||
|
|
@ -10,6 +10,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
|
|||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolDefinitions } from "./lib/tool"
|
||||
import { FSUtil } from "../src/fs-util"
|
||||
import { Auth } from "../src/auth"
|
||||
import { EventV2 } from "../src/event"
|
||||
|
|
@ -50,11 +51,11 @@ describe("LocationServiceMap", () => {
|
|||
).pipe(
|
||||
Effect.flatMap(([blocked, allowed]) =>
|
||||
Effect.gen(function* () {
|
||||
yield* (yield* ApplicationTools.Service).attach({
|
||||
yield* (yield* ApplicationTools.Service).register({
|
||||
application_context: Tool.make({
|
||||
description: "Read application context",
|
||||
parameters: Schema.Struct({}),
|
||||
success: Schema.Struct({ ok: Schema.Boolean }),
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ ok: true }),
|
||||
}),
|
||||
})
|
||||
|
|
@ -77,7 +78,7 @@ describe("LocationServiceMap", () => {
|
|||
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
||||
return {
|
||||
providers: yield* catalog.provider.all(),
|
||||
tools: yield* (yield* ToolRegistry.Service).definitions(),
|
||||
tools: yield* toolDefinitions(yield* ToolRegistry.Service),
|
||||
}
|
||||
}).pipe(Effect.scoped, Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(directory) })))
|
||||
|
||||
|
|
|
|||
|
|
@ -161,6 +161,21 @@ describe("PermissionV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("allows managed output reads without granting external directory access", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
])
|
||||
const service = yield* PermissionV2.Service
|
||||
|
||||
expect(yield* service.ask(assertion({ resources: ["tool_123"] }))).toMatchObject({ effect: "allow" })
|
||||
expect(
|
||||
yield* service.ask(assertion({ action: "external_directory", resources: ["/tmp/tool-output/*"] })),
|
||||
).toMatchObject({ effect: "deny" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses build permissions when the Session agent is omitted", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
|
|
|
|||
|
|
@ -30,11 +30,11 @@ describe("public native OpenCode API", () => {
|
|||
expect(Session.ID.create()).toStartWith("ses_")
|
||||
expect(Session.MessageID.create()).toStartWith("msg_")
|
||||
expect(yield* opencode.sessions.list()).toBeArray()
|
||||
yield* opencode.tools.attach({
|
||||
yield* opencode.tools.register({
|
||||
public_tool: Tool.make({
|
||||
description: "Public tool",
|
||||
parameters: Schema.Struct({}),
|
||||
success: Schema.Struct({ ok: Schema.Boolean }),
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ ok: true }),
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
13
packages/core/test/public-tool.test.ts
Normal file
13
packages/core/test/public-tool.test.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { describe, expect, it } from "bun:test"
|
||||
import { Tool } from "@opencode-ai/core/public"
|
||||
import { Effect } from "effect"
|
||||
|
||||
describe("public Tool API", () => {
|
||||
it("keeps the public registration capability narrow", () => {
|
||||
const tools = {
|
||||
register: () => Effect.void,
|
||||
} satisfies Tool.Interface
|
||||
|
||||
expect(Object.keys(tools)).toEqual(["register"])
|
||||
})
|
||||
})
|
||||
|
|
@ -1,65 +1,70 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Tool, ToolFailure } from "@opencode-ai/llm"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
|
||||
import { Effect, Exit, Layer, Schema, Scope } from "effect"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
let denyAction: string | undefined
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
PermissionV2.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const bounds: ToolOutputStore.BoundInput[] = []
|
||||
const retentionFailure = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") })
|
||||
const outputStore = Layer.mock(ToolOutputStore.Service, {
|
||||
bound: (input) => Effect.sync(() => bounds.push(input)).pipe(Effect.as({ output: input.output, outputPaths: [] })),
|
||||
bound: (input) => {
|
||||
if (input.toolCallID === "call-retention-failure") return Effect.fail(retentionFailure)
|
||||
return Effect.sync(() => bounds.push(input)).pipe(
|
||||
Effect.as(
|
||||
input.toolCallID === "call-bounded"
|
||||
? {
|
||||
output: { structured: {}, content: [{ type: "text" as const, text: "bounded reference" }] },
|
||||
outputPaths: ["/managed/generic"],
|
||||
}
|
||||
: { output: input.output, outputPaths: [] },
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
const registry = ToolRegistry.layer.pipe(Layer.provide(ApplicationTools.layer), Layer.provide(outputStore))
|
||||
const it = testEffect(registry)
|
||||
const integrated = testEffect(Layer.mergeAll(ApplicationTools.layer, registry))
|
||||
const identity = {
|
||||
agent: AgentV2.ID.make("build"),
|
||||
assistantMessageID: SessionMessage.ID.make("msg_registry"),
|
||||
}
|
||||
const sessionID = SessionV2.ID.make("ses_registry")
|
||||
const call = (name: string, id = `call-${name}`): ToolRegistry.ExecuteInput => ({
|
||||
sessionID,
|
||||
...identity,
|
||||
call: { type: "tool-call", id, name, input: { text: name } },
|
||||
})
|
||||
const registry = ToolRegistry.layer.pipe(
|
||||
Layer.provide(permission),
|
||||
Layer.provide(ApplicationTools.layer),
|
||||
Layer.provide(outputStore),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(permission, registry))
|
||||
|
||||
const echo = Tool.make({
|
||||
description: "Echo text",
|
||||
parameters: Schema.Struct({ text: Schema.String }),
|
||||
success: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.succeed({ text }),
|
||||
})
|
||||
const make = (permission?: string) => {
|
||||
const tool = Tool.make({
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.succeed({ text }),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
||||
})
|
||||
return permission ? Tool.withPermission(tool, permission) : tool
|
||||
}
|
||||
|
||||
describe("ToolRegistry", () => {
|
||||
it.effect("matches V1 whole-tool filtering, edit aliases, and ordered wildcard precedence", () =>
|
||||
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const transform = yield* registry.transform()
|
||||
const sessionID = SessionV2.ID.make("ses_registry_filter")
|
||||
yield* transform((editor) => {
|
||||
editor.set("question", { tool: echo })
|
||||
editor.set("bash", { tool: echo })
|
||||
editor.set("edit", { tool: echo })
|
||||
editor.set("write", { tool: echo })
|
||||
editor.set("apply_patch", { tool: echo })
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({
|
||||
question: make(),
|
||||
bash: make(),
|
||||
edit: make("edit"),
|
||||
write: make("edit"),
|
||||
apply_patch: make("edit"),
|
||||
})
|
||||
|
||||
const names = (rules: PermissionV2.Ruleset) =>
|
||||
registry.definitions(rules).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
|
||||
const names = (rules: Parameters<ToolRegistry.Interface["materialize"]>[0]) =>
|
||||
toolDefinitions(service, rules).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
|
||||
|
||||
expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual([
|
||||
"bash",
|
||||
|
|
@ -67,253 +72,374 @@ describe("ToolRegistry", () => {
|
|||
"write",
|
||||
"apply_patch",
|
||||
])
|
||||
|
||||
expect(
|
||||
yield* names([
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
{ action: "question", resource: "private", effect: "allow" },
|
||||
]),
|
||||
).toEqual(["question"])
|
||||
|
||||
expect(
|
||||
yield* names([
|
||||
{ action: "question", resource: "private", effect: "allow" },
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
]),
|
||||
).toEqual([])
|
||||
|
||||
expect(yield* names([{ action: "question", resource: "*", effect: "ask" }])).toContain("question")
|
||||
expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["question", "bash"])
|
||||
expect(
|
||||
yield* names([
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
{ action: "edit", resource: "*.md", effect: "ask" },
|
||||
]),
|
||||
).toEqual(["question", "bash", "edit", "write", "apply_patch"])
|
||||
expect(
|
||||
yield* names([
|
||||
{ action: "edit", resource: "*.md", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
]),
|
||||
).toEqual(["question", "bash"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("settles only through concrete leaf authorization, not catalog visibility", () =>
|
||||
it.effect("keeps permission decoration isolated between registrations", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const transform = yield* registry.transform()
|
||||
const sessionID = SessionV2.ID.make("ses_registry_stale")
|
||||
let executed = false
|
||||
yield* transform((editor) =>
|
||||
editor.set("question", {
|
||||
tool: echo,
|
||||
permission: { action: "question", resource: "*" },
|
||||
authorize: ({ assertPermission }) =>
|
||||
assertPermission({ action: "question", resources: ["actual"] }).pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: "Denied" })),
|
||||
),
|
||||
execute: () =>
|
||||
Effect.sync(() => {
|
||||
executed = true
|
||||
return { text: "unexpected" }
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const service = yield* ToolRegistry.Service
|
||||
const shared = make()
|
||||
yield* service.register({ first: shared })
|
||||
yield* service.register({ second: Tool.withPermission(shared, "edit") })
|
||||
Tool.withPermission(shared, "question")
|
||||
|
||||
expect(
|
||||
(yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).map((tool) => tool.name),
|
||||
).toEqual([])
|
||||
expect(
|
||||
yield* registry.settle({
|
||||
sessionID,
|
||||
call: { type: "tool-call", id: "call-stale", name: "question", input: { text: "hello" } },
|
||||
}),
|
||||
).toMatchObject({ result: { type: "json", value: { text: "unexpected" } } })
|
||||
expect(assertions.at(-1)).toMatchObject({ action: "question", resources: ["actual"] })
|
||||
expect(executed).toBe(true)
|
||||
(yield* toolDefinitions(service, [{ action: "edit", resource: "*", effect: "deny" }])).map(
|
||||
(definition) => definition.name,
|
||||
),
|
||||
).toEqual(["first"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rebuilds advertised definitions when a scoped transform closes", () =>
|
||||
it.effect("reuses model definitions across provider turns", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ echo: make() })
|
||||
const first = yield* toolDefinitions(service)
|
||||
const second = yield* toolDefinitions(service)
|
||||
|
||||
expect(second[0]).toBe(first[0])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes a scoped registration", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
const transform = yield* registry.transform().pipe(Scope.provide(scope))
|
||||
|
||||
yield* transform((editor) => editor.set("echo", { tool: echo, authorize: () => Effect.void }))
|
||||
expect(yield* registry.definitions()).toMatchObject([{ name: "echo", description: "Echo text" }])
|
||||
|
||||
yield* service.register({ echo: make() }).pipe(Scope.provide(scope))
|
||||
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* registry.definitions()).toEqual([])
|
||||
expect(yield* toolDefinitions(service)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns an error result for an unknown tool", () =>
|
||||
it.effect("preserves an interrupted registration until its scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const service = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
const registered = yield* Deferred.make<void>()
|
||||
const fiber = yield* service
|
||||
.register({ echo: make() })
|
||||
.pipe(
|
||||
Effect.andThen(Deferred.succeed(registered, undefined)),
|
||||
Effect.andThen(Effect.never),
|
||||
Scope.provide(scope),
|
||||
Effect.forkChild,
|
||||
)
|
||||
yield* Deferred.await(registered)
|
||||
yield* Fiber.interrupt(fiber)
|
||||
|
||||
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* toolDefinitions(service)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns model errors without swallowing interruption or defects", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({
|
||||
failed: Tool.make({
|
||||
description: "Failed",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })),
|
||||
}),
|
||||
})
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
sessionID: SessionV2.ID.make("ses_registry_test"),
|
||||
call: { type: "tool-call", id: "call-missing", name: "missing", input: {} },
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
...identity,
|
||||
call: { type: "tool-call", id: "failed", name: "failed", input: {} },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Denied" })
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
...identity,
|
||||
call: { type: "tool-call", id: "missing", name: "missing", input: {} },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Unknown tool: missing" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not execute a tool when authorization fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
let executed = false
|
||||
const transform = yield* registry.transform()
|
||||
|
||||
yield* transform((editor) =>
|
||||
editor.set("denied", {
|
||||
authorize: () => Effect.fail(new ToolFailure({ message: "Denied" })),
|
||||
tool: Tool.make({
|
||||
description: "Denied tool",
|
||||
parameters: Schema.Struct({}),
|
||||
success: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () =>
|
||||
Effect.sync(() => {
|
||||
executed = true
|
||||
return { ok: true }
|
||||
}),
|
||||
}),
|
||||
yield* service.register({
|
||||
defect: Tool.make({
|
||||
description: "Defect",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.die("unexpected executor defect"),
|
||||
}),
|
||||
)
|
||||
|
||||
})
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
sessionID: SessionV2.ID.make("ses_registry_test"),
|
||||
call: { type: "tool-call", id: "call-denied", name: "denied", input: {} },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Denied" })
|
||||
expect(executed).toBe(false)
|
||||
yield* service.materialize().pipe(
|
||||
Effect.flatMap((materialized) =>
|
||||
materialized.settle({
|
||||
sessionID,
|
||||
...identity,
|
||||
call: { type: "tool-call", id: "defect", name: "defect", input: {} },
|
||||
}),
|
||||
),
|
||||
Effect.catchDefect(Effect.succeed),
|
||||
),
|
||||
).toBe("unexpected executor defect")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("binds invocation identity while preserving leaf-owned permission inputs", () =>
|
||||
it.effect("propagates retention failures through settlement", () =>
|
||||
Effect.gen(function* () {
|
||||
assertions.length = 0
|
||||
denyAction = undefined
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const transform = yield* registry.transform()
|
||||
const sessionID = SessionV2.ID.make("ses_registry_context")
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ echo: make() })
|
||||
const materialized = yield* service.materialize()
|
||||
const exit = yield* materialized.settle(call("echo", "call-retention-failure")).pipe(Effect.exit)
|
||||
|
||||
yield* transform((editor) =>
|
||||
editor.set("context", {
|
||||
tool: Tool.make({
|
||||
description: "Context tool",
|
||||
parameters: Schema.Struct({}),
|
||||
success: Schema.Struct({ ok: Schema.Boolean }),
|
||||
}),
|
||||
execute: ({ assertPermission, call, source }) =>
|
||||
assertPermission({
|
||||
action: "inspect",
|
||||
resources: [call.id],
|
||||
save: ["*"],
|
||||
metadata: { tool: call.name },
|
||||
}).pipe(
|
||||
Effect.as({ ok: source === undefined }),
|
||||
Effect.catch(() => Effect.fail(new ToolFailure({ message: "Denied" }))),
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
sessionID,
|
||||
call: { type: "tool-call", id: "call-context", name: "context", input: {} },
|
||||
}),
|
||||
).toEqual({ type: "json", value: { ok: true } })
|
||||
expect(assertions).toEqual([
|
||||
{
|
||||
sessionID,
|
||||
action: "inspect",
|
||||
resources: ["call-context"],
|
||||
save: ["*"],
|
||||
metadata: { tool: "context" },
|
||||
},
|
||||
])
|
||||
expect(assertions[0]).not.toHaveProperty("source")
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBe(retentionFailure)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps ordered multi-assert policy flow in the leaf and stops on denial", () =>
|
||||
it.effect("exposes settlement only through materialization", () =>
|
||||
Effect.gen(function* () {
|
||||
assertions.length = 0
|
||||
denyAction = "execute"
|
||||
let executed = false
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const transform = yield* registry.transform()
|
||||
|
||||
yield* transform((editor) =>
|
||||
editor.set("ordered", {
|
||||
tool: Tool.make({
|
||||
description: "Ordered policy tool",
|
||||
parameters: Schema.Struct({}),
|
||||
success: Schema.Struct({ ok: Schema.Boolean }),
|
||||
}),
|
||||
execute: ({ assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* assertPermission({ action: "external_directory", resources: ["/outside/*"] })
|
||||
yield* assertPermission({ action: "execute", resources: ["pwd"] })
|
||||
executed = true
|
||||
return { ok: true }
|
||||
}).pipe(Effect.catch(() => Effect.fail(new ToolFailure({ message: "Denied" })))),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
sessionID: SessionV2.ID.make("ses_registry_context"),
|
||||
call: { type: "tool-call", id: "call-ordered", name: "ordered", input: {} },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Denied" })
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "execute"])
|
||||
expect(executed).toBe(false)
|
||||
denyAction = undefined
|
||||
const service = yield* ToolRegistry.Service
|
||||
expect("definitions" in service).toBe(false)
|
||||
expect("execute" in service).toBe(false)
|
||||
expect("settle" in service).toBe(false)
|
||||
expect(typeof service.materialize).toBe("function")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("settles encoded structured output with canonical projected content", () =>
|
||||
it.effect("passes complete invocation identity to the canonical handler", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const contexts: Tool.Context[] = []
|
||||
yield* service.register({
|
||||
context: Tool.make({
|
||||
description: "Context",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })),
|
||||
}),
|
||||
})
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
...identity,
|
||||
call: { type: "tool-call", id: "call-context", name: "context", input: {} },
|
||||
})
|
||||
expect(contexts).toEqual([{ sessionID, ...identity, toolCallID: "call-context" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("encodes output and applies generic settlement bounding", () =>
|
||||
Effect.gen(function* () {
|
||||
bounds.length = 0
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const transform = yield* registry.transform()
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ bounded: make() })
|
||||
expect(
|
||||
yield* settleTool(service, {
|
||||
sessionID,
|
||||
...identity,
|
||||
call: { type: "tool-call", id: "call-bounded", name: "bounded", input: { text: "complete" } },
|
||||
}),
|
||||
).toEqual({
|
||||
result: { type: "text", value: "bounded reference" },
|
||||
output: { structured: {}, content: [{ type: "text", text: "bounded reference" }] },
|
||||
outputPaths: ["/managed/generic"],
|
||||
})
|
||||
expect(bounds).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
yield* transform((editor) =>
|
||||
editor.set("projected", {
|
||||
tool: Tool.make({
|
||||
description: "Projected tool",
|
||||
parameters: Schema.Struct({ prefix: Schema.String }),
|
||||
success: Schema.Struct({ count: Schema.NumberFromString }),
|
||||
execute: () => Effect.succeed({ count: 2 }),
|
||||
toModelOutput: ({ callID, parameters, output }) => [
|
||||
{ type: "text", text: `${callID}:${parameters.prefix}:${output.count}` },
|
||||
],
|
||||
}),
|
||||
it.effect("enforces transformed codecs at execution and projection boundaries", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const executed: string[] = []
|
||||
const Transformed = Schema.Boolean.pipe(
|
||||
Schema.decodeTo(Schema.String, {
|
||||
decode: SchemaGetter.transform((value) => (value ? "yes" : "no")),
|
||||
encode: SchemaGetter.transform((value) => value === "yes"),
|
||||
}),
|
||||
)
|
||||
yield* service.register({
|
||||
transformed: Tool.make({
|
||||
description: "Transform values",
|
||||
input: Schema.Struct({ value: Transformed }),
|
||||
output: Schema.Struct({ value: Transformed }),
|
||||
execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }],
|
||||
}),
|
||||
})
|
||||
|
||||
expect(
|
||||
yield* registry.settle({
|
||||
sessionID: SessionV2.ID.make("ses_registry_test"),
|
||||
call: { type: "tool-call", id: "call-projected", name: "projected", input: { prefix: "count" } },
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
...identity,
|
||||
call: { type: "tool-call", id: "transformed", name: "transformed", input: { value: true } },
|
||||
}),
|
||||
).toEqual({ type: "text", value: "true" })
|
||||
expect(executed).toEqual(["yes"])
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
...identity,
|
||||
call: { type: "tool-call", id: "invalid-input", name: "transformed", input: { value: "yes" } },
|
||||
}),
|
||||
).toMatchObject({ type: "error", value: expect.stringContaining("Invalid tool input") })
|
||||
expect(executed).toEqual(["yes"])
|
||||
|
||||
yield* service.register({
|
||||
invalid_output: Tool.make({
|
||||
description: "Return invalid output",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({
|
||||
value: Schema.Boolean.pipe(
|
||||
Schema.decodeTo(Schema.String, {
|
||||
decode: SchemaGetter.transform((value) => String(value)),
|
||||
encode: SchemaGetter.transformOrFail((value) =>
|
||||
value === "valid"
|
||||
? Effect.succeed(true)
|
||||
: Effect.fail(new SchemaIssue.InvalidValue(Option.some(value), { message: "invalid output" })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
execute: () => Effect.succeed({ value: "invalid" }),
|
||||
}),
|
||||
).toMatchObject({
|
||||
result: { type: "text", value: "call-projected:count:2" },
|
||||
output: { structured: { count: "2" }, content: [{ type: "text", text: "call-projected:count:2" }] },
|
||||
})
|
||||
expect(bounds).toEqual([
|
||||
{
|
||||
sessionID: SessionV2.ID.make("ses_registry_test"),
|
||||
toolCallID: "call-projected",
|
||||
output: { structured: { count: "2" }, content: [{ type: "text", text: "call-projected:count:2" }] },
|
||||
},
|
||||
])
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
...identity,
|
||||
call: { type: "tool-call", id: "invalid-output", name: "invalid_output", input: {} },
|
||||
}),
|
||||
).toMatchObject({ type: "error", value: expect.stringContaining("invalid value for its output schema") })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("executes the unchanged registration advertised for a provider turn", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ echo: make() })
|
||||
const materialized = yield* service.materialize()
|
||||
|
||||
expect((yield* materialized.settle(call("echo"))).result).toEqual({ type: "text", value: "echo" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a call when its advertised registration was removed", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
yield* service.register({ echo: make() }).pipe(Scope.provide(scope))
|
||||
const materialized = yield* service.materialize()
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
|
||||
expect((yield* materialized.settle(call("echo"))).result).toEqual({
|
||||
type: "error",
|
||||
value: "Stale tool call: echo",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects only the replaced name from a multi-tool provider turn", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ first: make(), second: make() })
|
||||
const materialized = yield* service.materialize()
|
||||
yield* service.register({ first: make() })
|
||||
|
||||
expect((yield* materialized.settle(call("first"))).result).toEqual({
|
||||
type: "error",
|
||||
value: "Stale tool call: first",
|
||||
})
|
||||
expect((yield* materialized.settle(call("second"))).result).toEqual({ type: "text", value: "second" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats revealing a previous overlay as stale", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ echo: make() })
|
||||
const overlay = yield* Scope.make()
|
||||
yield* service.register({ echo: make() }).pipe(Scope.provide(overlay))
|
||||
const materialized = yield* service.materialize()
|
||||
yield* Scope.close(overlay, Exit.void)
|
||||
|
||||
expect((yield* materialized.settle(call("echo"))).result).toEqual({
|
||||
type: "error",
|
||||
value: "Stale tool call: echo",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
integrated.effect("rejects an application call after a Location override is registered", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* applications.register({ echo: make() })
|
||||
const materialized = yield* service.materialize()
|
||||
yield* service.register({ echo: make() })
|
||||
|
||||
expect((yield* materialized.settle(call("echo"))).result).toEqual({
|
||||
type: "error",
|
||||
value: "Stale tool call: echo",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
integrated.effect("rejects a Location call after removal reveals an application registration", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* applications.register({ echo: make() })
|
||||
const scope = yield* Scope.make()
|
||||
yield* service.register({ echo: make() }).pipe(Scope.provide(scope))
|
||||
const materialized = yield* service.materialize()
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
|
||||
expect((yield* materialized.settle(call("echo"))).result).toEqual({
|
||||
type: "error",
|
||||
value: "Stale tool call: echo",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps captured execution running after registration mutation", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const scope = yield* Scope.make()
|
||||
yield* service
|
||||
.register({
|
||||
echo: Tool.make({
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) =>
|
||||
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as({ text })),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
||||
}),
|
||||
})
|
||||
.pipe(Scope.provide(scope))
|
||||
const materialized = yield* service.materialize()
|
||||
const settlement = yield* materialized.settle(call("echo")).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(started)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* service.register({ echo: make() })
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
|
||||
expect(yield* Fiber.join(settlement)).toMatchObject({ result: { type: "text", value: "echo" } })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import {
|
|||
LLMError,
|
||||
LLMEvent,
|
||||
Model,
|
||||
Tool,
|
||||
TransportReason,
|
||||
InvalidRequestReason,
|
||||
type LLMClientShape,
|
||||
|
|
@ -38,7 +37,7 @@ import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
|
|||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigCompaction } from "@opencode-ai/core/config/compaction"
|
||||
import { NativeTool } from "@opencode-ai/core/tool/native"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import {
|
||||
SessionContextEpochTable,
|
||||
SessionInputTable,
|
||||
|
|
@ -110,7 +109,7 @@ const recoveryModel = Model.make({
|
|||
provider: "fake",
|
||||
route: OpenAIChat.route.with({ limits: { context: 20_000, output: 1_000 } }),
|
||||
})
|
||||
const authorizations: ToolRegistry.AuthorizeInput[] = []
|
||||
const authorizations: Tool.Context[] = []
|
||||
const executions: string[] = []
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
|
|
@ -132,38 +131,31 @@ const registry = ToolRegistry.layer.pipe(
|
|||
const agents = AgentV2.layer
|
||||
const echo = Layer.effectDiscard(
|
||||
ToolRegistry.Service.use((registry) =>
|
||||
registry.contribute((editor) => {
|
||||
;(editor.set("echo", {
|
||||
authorize: (input) =>
|
||||
Effect.sync(() => {
|
||||
authorizations.push(input)
|
||||
}),
|
||||
tool: Tool.make({
|
||||
description: "Echo text",
|
||||
parameters: Schema.Struct({ text: Schema.String }),
|
||||
success: Schema.Struct({ text: Schema.String }),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
||||
execute: ({ text }) =>
|
||||
Effect.gen(function* () {
|
||||
executions.push(text)
|
||||
activeToolExecutions++
|
||||
maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
|
||||
if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) {
|
||||
yield* Deferred.succeed(toolExecutionsStarted, undefined)
|
||||
}
|
||||
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
|
||||
return { text }
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
|
||||
}),
|
||||
registry.register({
|
||||
echo: Tool.make({
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
||||
execute: ({ text }, context) =>
|
||||
Effect.gen(function* () {
|
||||
authorizations.push(context)
|
||||
executions.push(text)
|
||||
activeToolExecutions++
|
||||
maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
|
||||
if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) {
|
||||
yield* Deferred.succeed(toolExecutionsStarted, undefined)
|
||||
}
|
||||
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
|
||||
return { text }
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
|
||||
}),
|
||||
defect: Tool.make({
|
||||
description: "Fail unexpectedly",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.die("unexpected tool defect"),
|
||||
}),
|
||||
editor.set("defect", {
|
||||
tool: Tool.make({
|
||||
description: "Fail unexpectedly",
|
||||
parameters: Schema.Struct({}),
|
||||
success: Schema.Struct({}),
|
||||
execute: () => Effect.die("unexpected tool defect"),
|
||||
}),
|
||||
}))
|
||||
}),
|
||||
),
|
||||
).pipe(Layer.provide(registry))
|
||||
|
|
@ -567,12 +559,12 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* setup
|
||||
const applicationTools = yield* ApplicationTools.Service
|
||||
const session = yield* SessionV2.Service
|
||||
const contexts: NativeTool.Context[] = []
|
||||
yield* applicationTools.attach({
|
||||
application_context: NativeTool.make({
|
||||
const contexts: Tool.Context[] = []
|
||||
yield* applicationTools.register({
|
||||
application_context: Tool.make({
|
||||
description: "Read application context",
|
||||
parameters: Schema.Struct({ query: Schema.String }),
|
||||
success: Schema.Struct({ answer: Schema.String }),
|
||||
input: Schema.Struct({ query: Schema.String }),
|
||||
output: Schema.Struct({ answer: Schema.String }),
|
||||
execute: ({ query }, context) =>
|
||||
Effect.sync(() => {
|
||||
contexts.push(context)
|
||||
|
|
@ -594,7 +586,14 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toContain("application_context")
|
||||
expect(contexts).toEqual([{ sessionID, id: "call-application", name: "application_context" }])
|
||||
expect(contexts).toEqual([
|
||||
{
|
||||
sessionID,
|
||||
agent: AgentV2.ID.make("build"),
|
||||
assistantMessageID: expect.stringMatching(/^msg_/),
|
||||
toolCallID: "call-application",
|
||||
},
|
||||
])
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Use application context" },
|
||||
{
|
||||
|
|
@ -1783,7 +1782,7 @@ describe("SessionRunnerLLM", () => {
|
|||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
|
||||
expect(authorizations).toMatchObject([{ sessionID, call: { id: "call-echo", name: "echo" } }])
|
||||
expect(authorizations).toMatchObject([{ sessionID, toolCallID: "call-echo" }])
|
||||
expect(executions).toEqual(["hello"])
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Echo this" },
|
||||
|
|
@ -2937,7 +2936,7 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("durably settles unexpected local tool defects before continuing", () =>
|
||||
it.effect("propagates unexpected local tool defects operationally", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
|
|
@ -2951,12 +2950,11 @@ describe("SessionRunnerLLM", () => {
|
|||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
[],
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe("unexpected tool defect")
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Call defect" },
|
||||
{
|
||||
|
|
@ -2965,7 +2963,10 @@ describe("SessionRunnerLLM", () => {
|
|||
{
|
||||
type: "tool",
|
||||
id: "call-defect",
|
||||
state: { status: "error", error: { message: "unexpected tool defect" } },
|
||||
state: {
|
||||
status: "error",
|
||||
error: { type: "unknown", message: "Tool execution failed: unexpected tool defect" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -2979,17 +2980,15 @@ describe("SessionRunnerLLM", () => {
|
|||
const session = yield* SessionV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const questions = yield* QuestionV2.Service
|
||||
const transform = yield* registry.transform()
|
||||
yield* transform((editor) =>
|
||||
editor.set("question", {
|
||||
tool: Tool.make({
|
||||
description: "Ask the user",
|
||||
parameters: Schema.Struct({}),
|
||||
success: Schema.Struct({}),
|
||||
}),
|
||||
execute: ({ sessionID }) => questions.ask({ sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie),
|
||||
yield* registry.register({
|
||||
question: Tool.make({
|
||||
description: "Ask the user",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: (_, context) =>
|
||||
questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie),
|
||||
}),
|
||||
)
|
||||
})
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Ask then stop" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
|
|
|
|||
34
packages/core/test/state.test.ts
Normal file
34
packages/core/test/state.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
describe("State", () => {
|
||||
it.effect("commits a transform atomically when its updater is interrupted", () =>
|
||||
Effect.gen(function* () {
|
||||
const rebuilding = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
let block = true
|
||||
const state = State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
editor: (draft) => ({ add: (value: string) => draft.values.push(value) }),
|
||||
finalize: () =>
|
||||
block ? Deferred.succeed(rebuilding, undefined).pipe(Effect.andThen(Deferred.await(release))) : Effect.void,
|
||||
})
|
||||
const scope = yield* Scope.make()
|
||||
const update = yield* state.transform().pipe(Scope.provide(scope))
|
||||
const fiber = yield* update((editor) => editor.add("registered")).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(rebuilding)
|
||||
const interruption = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild)
|
||||
block = false
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(interruption)
|
||||
|
||||
expect(state.get().values).toEqual(["registered"])
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(state.get().values).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer } from "effect"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
|
|
@ -14,6 +14,7 @@ import { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch"
|
|||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_apply_patch_tool_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
|
|
@ -92,6 +93,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
|
|||
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
|
||||
const patch = ApplyPatchTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(permission),
|
||||
Layer.provide(resolution),
|
||||
Layer.provide(mutation),
|
||||
Layer.provide(filesystem),
|
||||
|
|
@ -103,6 +105,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
|
|||
|
||||
const call = (patchText: string, id = "call-apply-patch") => ({
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call" as const, id, name: "apply_patch", input: { patchText } },
|
||||
})
|
||||
|
||||
|
|
@ -129,8 +132,9 @@ describe("ApplyPatchTool", () => {
|
|||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["apply_patch"])
|
||||
const settled = yield* registry.settle(
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["apply_patch"])
|
||||
const settled = yield* settleTool(
|
||||
registry,
|
||||
call(
|
||||
"*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Update File: update.txt\n@@\n-before\n+after\n*** Delete File: remove.txt\n*** End Patch",
|
||||
),
|
||||
|
|
@ -146,7 +150,7 @@ describe("ApplyPatchTool", () => {
|
|||
{ type: "delete", resource: "remove.txt" },
|
||||
],
|
||||
})
|
||||
expect(assertions).toEqual([
|
||||
expect(assertions).toMatchObject([
|
||||
{ sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] },
|
||||
])
|
||||
expect(readsBeforeEditApproval).toBe(0)
|
||||
|
|
@ -175,7 +179,8 @@ describe("ApplyPatchTool", () => {
|
|||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* registry.execute(
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call(
|
||||
"*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch",
|
||||
),
|
||||
|
|
@ -203,7 +208,8 @@ describe("ApplyPatchTool", () => {
|
|||
withTool(active.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* registry.execute(
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`),
|
||||
),
|
||||
).toMatchObject({ type: "text" })
|
||||
|
|
@ -236,7 +242,8 @@ describe("ApplyPatchTool", () => {
|
|||
withTool(active.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* registry.execute(
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call(
|
||||
`*** Begin Patch\n*** Update File: ${first}\n@@\n-before\n+after\n*** Update File: ${second}\n@@\n-before\n+after\n*** End Patch`,
|
||||
),
|
||||
|
|
@ -266,7 +273,8 @@ describe("ApplyPatchTool", () => {
|
|||
return withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* registry.execute(
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call(
|
||||
"*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: missing.txt\n@@\n-before\n+after\n*** End Patch",
|
||||
),
|
||||
|
|
@ -291,7 +299,8 @@ describe("ApplyPatchTool", () => {
|
|||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* registry.execute(
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Add File: existing.txt\n+replacement\n*** End Patch"),
|
||||
),
|
||||
).toEqual({ type: "error", value: "Unable to apply patch at existing.txt" })
|
||||
|
|
@ -315,7 +324,10 @@ describe("ApplyPatchTool", () => {
|
|||
return withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* registry.execute(call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch")),
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch"),
|
||||
),
|
||||
).toEqual({ type: "error", value: "Unable to apply patch at appeared.txt" })
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("winner\n")
|
||||
}),
|
||||
|
|
@ -325,7 +337,7 @@ describe("ApplyPatchTool", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("reports earlier sequential applications when a later commit fails", () =>
|
||||
it.live("preserves a later commit defect after earlier sequential applications", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
|
|
@ -338,13 +350,13 @@ describe("ApplyPatchTool", () => {
|
|||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* registry.execute(
|
||||
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
|
||||
Exit.isFailure(
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
|
||||
).pipe(Effect.exit),
|
||||
),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value: "Patch partially applied before failing at second.txt. Applied: first.txt",
|
||||
})
|
||||
).toBe(true)
|
||||
expect(yield* exists(first)).toBe(false)
|
||||
expect(yield* exists(second)).toBe(true)
|
||||
}),
|
||||
|
|
@ -370,11 +382,10 @@ describe("ApplyPatchTool", () => {
|
|||
yield* Effect.promise(() => Promise.all([fs.writeFile(first, "first"), fs.writeFile(second, "second")]))
|
||||
yield* withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const run = yield* registry
|
||||
.execute(
|
||||
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
|
||||
)
|
||||
.pipe(Effect.forkChild)
|
||||
const run = yield* executeTool(
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
|
||||
).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(removeStarted!)
|
||||
const interrupt = yield* Fiber.interrupt(run).pipe(Effect.forkChild)
|
||||
yield* Deferred.succeed(releaseRemove!, undefined)
|
||||
|
|
|
|||
|
|
@ -13,11 +13,11 @@ import { AppProcess } from "@opencode-ai/core/process"
|
|||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { BashTool } from "@opencode-ai/core/tool/bash"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_bash_tool_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
|
|
@ -27,7 +27,6 @@ const runs: Array<{
|
|||
readonly shell?: string | boolean
|
||||
readonly options?: AppProcess.RunOptions
|
||||
}> = []
|
||||
const truncations: ToolOutputStore.TruncateInput[] = []
|
||||
let denyAction: string | undefined
|
||||
let result: AppProcess.RunResult = {
|
||||
command: "mock",
|
||||
|
|
@ -39,8 +38,6 @@ let result: AppProcess.RunResult = {
|
|||
}
|
||||
let runFailure: AppProcess.AppProcessError | undefined
|
||||
let afterPermission = (_input: PermissionV2.AssertInput): Effect.Effect<void> => Effect.void
|
||||
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
|
||||
Effect.succeed({ content: input.content, truncated: false })
|
||||
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
|
|
@ -70,16 +67,6 @@ const appProcess = Layer.succeed(
|
|||
}),
|
||||
} as unknown as AppProcess.Interface),
|
||||
)
|
||||
const resources = Layer.succeed(
|
||||
ToolOutputStore.Service,
|
||||
ToolOutputStore.Service.of({
|
||||
limits: () => Effect.die("unused"),
|
||||
write: () => Effect.die("unused"),
|
||||
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
|
||||
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
|
||||
cleanup: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const config = Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
|
|
@ -90,7 +77,6 @@ const config = Layer.succeed(
|
|||
const reset = () => {
|
||||
assertions.length = 0
|
||||
runs.length = 0
|
||||
truncations.length = 0
|
||||
denyAction = undefined
|
||||
runFailure = undefined
|
||||
afterPermission = () => Effect.void
|
||||
|
|
@ -102,7 +88,6 @@ const reset = () => {
|
|||
stdoutTruncated: false,
|
||||
stderrTruncated: false,
|
||||
}
|
||||
truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
|
||||
}
|
||||
|
||||
const withTool = <A, E, R>(
|
||||
|
|
@ -123,7 +108,6 @@ const withTool = <A, E, R>(
|
|||
Layer.provide(mutation),
|
||||
Layer.provide(filesystem),
|
||||
Layer.provide(processLayer),
|
||||
Layer.provide(resources),
|
||||
Layer.provide(config),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
|
|
@ -131,8 +115,9 @@ const withTool = <A, E, R>(
|
|||
}).pipe(Effect.provide(Layer.mergeAll(registry, bash)))
|
||||
}
|
||||
|
||||
const call = (input: typeof BashTool.Parameters.Type, id = "call-bash") => ({
|
||||
const call = (input: typeof BashTool.Input.Type, id = "call-bash") => ({
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call" as const, id, name: "bash", input },
|
||||
})
|
||||
|
||||
|
|
@ -146,11 +131,13 @@ describe("BashTool", () => {
|
|||
reset()
|
||||
return withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const definitions = yield* registry.definitions()
|
||||
const definitions = yield* toolDefinitions(registry)
|
||||
expect(definitions.map((tool) => tool.name)).toEqual(["bash"])
|
||||
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background")
|
||||
expect(yield* registry.definitions([{ action: "bash", resource: "*", effect: "deny" }])).toEqual([])
|
||||
expect(yield* registry.settle(call({ command: "pwd", description: "Print working directory" }))).toEqual({
|
||||
expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([])
|
||||
expect(
|
||||
yield* settleTool(registry, call({ command: "pwd", description: "Print working directory" })),
|
||||
).toEqual({
|
||||
result: { type: "text", value: "hello\n\n\nCommand exited with code 0." },
|
||||
output: {
|
||||
structured: {
|
||||
|
|
@ -168,7 +155,7 @@ describe("BashTool", () => {
|
|||
maxOutputBytes: BashTool.MAX_CAPTURE_BYTES,
|
||||
maxErrorBytes: BashTool.MAX_CAPTURE_BYTES,
|
||||
})
|
||||
expect(assertions).toEqual([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }])
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }])
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
|
@ -182,7 +169,9 @@ describe("BashTool", () => {
|
|||
(tmp) => {
|
||||
reset()
|
||||
return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe(
|
||||
Effect.andThen(withTool(tmp.path, (registry) => registry.execute(call({ command: "pwd", workdir: "src" })))),
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) => executeTool(registry, call({ command: "pwd", workdir: "src" }))),
|
||||
),
|
||||
Effect.andThen(
|
||||
Effect.sync(() => expect(runs).toMatchObject([{ cwd: realpathSync(path.join(tmp.path, "src")) }])),
|
||||
),
|
||||
|
|
@ -206,7 +195,9 @@ describe("BashTool", () => {
|
|||
}).pipe(Effect.orDie)
|
||||
: Effect.void
|
||||
return Effect.promise(() => fs.mkdir(workdir)).pipe(
|
||||
Effect.andThen(withTool(tmp.path, (registry) => registry.execute(call({ command: "pwd", workdir: "src" })))),
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) => executeTool(registry, call({ command: "pwd", workdir: "src" }))),
|
||||
),
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(runs).toEqual([])
|
||||
|
|
@ -227,7 +218,7 @@ describe("BashTool", () => {
|
|||
reset()
|
||||
return withTool(
|
||||
tmp.path,
|
||||
(registry) => registry.settle(call({ command: "printf core-bash" })),
|
||||
(registry) => settleTool(registry, call({ command: "printf core-bash" })),
|
||||
AppProcess.defaultLayer,
|
||||
).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
|
|
@ -254,7 +245,7 @@ describe("BashTool", () => {
|
|||
([active, outside]) => {
|
||||
reset()
|
||||
return withTool(active.path, (registry) =>
|
||||
registry.execute(call({ command: "pwd", workdir: outside.path })),
|
||||
executeTool(registry, call({ command: "pwd", workdir: outside.path })),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
|
|
@ -281,13 +272,15 @@ describe("BashTool", () => {
|
|||
Effect.gen(function* () {
|
||||
reset()
|
||||
denyAction = "external_directory"
|
||||
yield* withTool(active.path, (registry) => registry.execute(call({ command: "pwd", workdir: outside.path })))
|
||||
yield* withTool(active.path, (registry) =>
|
||||
executeTool(registry, call({ command: "pwd", workdir: outside.path })),
|
||||
)
|
||||
expect(assertions.map((item) => item.action)).toEqual(["external_directory"])
|
||||
expect(runs).toEqual([])
|
||||
|
||||
reset()
|
||||
denyAction = "bash"
|
||||
yield* withTool(active.path, (registry) => registry.execute(call({ command: "pwd" })))
|
||||
yield* withTool(active.path, (registry) => executeTool(registry, call({ command: "pwd" })))
|
||||
expect(assertions.map((item) => item.action)).toEqual(["bash"])
|
||||
expect(runs).toEqual([])
|
||||
}),
|
||||
|
|
@ -305,7 +298,7 @@ describe("BashTool", () => {
|
|||
reset()
|
||||
denyAction = "external_directory"
|
||||
const target = path.join(outside.path, "secret.txt")
|
||||
return withTool(active.path, (registry) => registry.settle(call({ command: `cat ${target}` }))).pipe(
|
||||
return withTool(active.path, (registry) => settleTool(registry, call({ command: `cat ${target}` }))).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
expect(assertions.map((item) => item.action)).toEqual(["bash"])
|
||||
|
|
@ -327,19 +320,13 @@ describe("BashTool", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("keeps non-zero exits useful and exposes managed overflow by path", () =>
|
||||
it.live("keeps non-zero exits useful", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
result = { ...result, exitCode: 7, stdout: Buffer.from("HEAD full output TAIL") }
|
||||
truncate = (input) =>
|
||||
Effect.succeed({
|
||||
content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL",
|
||||
truncated: true,
|
||||
outputPath: "/tmp/tool-output/tool_opaque",
|
||||
})
|
||||
return withTool(tmp.path, (registry) => registry.settle(call({ command: "false" }, "call-overflow"))).pipe(
|
||||
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "false" }, "call-overflow"))).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
expect(settled.result).toMatchObject({
|
||||
|
|
@ -350,13 +337,9 @@ describe("BashTool", () => {
|
|||
command: "false",
|
||||
cwd: realpathSync(tmp.path),
|
||||
exitCode: 7,
|
||||
truncated: true,
|
||||
outputPath: "/tmp/tool-output/tool_opaque",
|
||||
output: "HEAD full output TAIL",
|
||||
truncated: false,
|
||||
})
|
||||
expect(settled.outputPaths).toEqual(["/tmp/tool-output/tool_opaque"])
|
||||
expect(truncations).toMatchObject([
|
||||
{ sessionID, toolCallID: "call-overflow", content: "HEAD full output TAIL" },
|
||||
])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -371,7 +354,7 @@ describe("BashTool", () => {
|
|||
(tmp) => {
|
||||
reset()
|
||||
result = { ...result, stdoutTruncated: true }
|
||||
return withTool(tmp.path, (registry) => registry.settle(call({ command: "verbose" }))).pipe(
|
||||
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "verbose" }))).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
expect(settled.output?.structured).toMatchObject({ truncated: true, stdoutTruncated: true })
|
||||
|
|
@ -394,7 +377,7 @@ describe("BashTool", () => {
|
|||
(tmp) => {
|
||||
reset()
|
||||
runFailure = new AppProcess.AppProcessError({ command: "sleep", cause: new Error("Timed out") })
|
||||
return withTool(tmp.path, (registry) => registry.settle(call({ command: "sleep 60", timeout: 10 }))).pipe(
|
||||
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "sleep 60", timeout: 10 }))).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
expect(settled.result).toMatchObject({
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { EditTool } from "@opencode-ai/core/tool/edit"
|
|||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_edit_tool_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
|
|
@ -82,6 +83,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
|
|||
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
|
||||
const edit = EditTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(permission),
|
||||
Layer.provide(resolution),
|
||||
Layer.provide(mutation),
|
||||
Layer.provide(filesystem),
|
||||
|
|
@ -91,8 +93,9 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
|
|||
}).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, edit)))
|
||||
}
|
||||
|
||||
const call = (input: typeof EditTool.Parameters.Type, id = "call-edit") => ({
|
||||
const call = (input: typeof EditTool.Input.Type, id = "call-edit") => ({
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call" as const, id, name: "edit", input },
|
||||
})
|
||||
|
||||
|
|
@ -109,9 +112,12 @@ describe("EditTool", () => {
|
|||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["edit"])
|
||||
expect(yield* registry.definitions([{ action: "edit", resource: "*", effect: "deny" }])).toEqual([])
|
||||
const settled = yield* registry.settle(
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["edit"])
|
||||
expect(yield* toolDefinitions(registry, [{ action: "edit", resource: "*", effect: "deny" }])).toEqual(
|
||||
[],
|
||||
)
|
||||
const settled = yield* settleTool(
|
||||
registry,
|
||||
call({ path: "hello.txt", oldString: "before", newString: "after" }),
|
||||
)
|
||||
expect(settled.result).toEqual({
|
||||
|
|
@ -126,7 +132,7 @@ describe("EditTool", () => {
|
|||
replacements: 1,
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
|
||||
expect(assertions).toEqual([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
|
||||
expect(writes).toEqual([yield* Effect.promise(() => fs.realpath(target))])
|
||||
}),
|
||||
),
|
||||
|
|
@ -146,7 +152,7 @@ describe("EditTool", () => {
|
|||
return Effect.promise(() => fs.writeFile(target, "before")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
registry.execute(call({ path: target, oldString: "before", newString: "after" })),
|
||||
executeTool(registry, call({ path: target, oldString: "before", newString: "after" })),
|
||||
),
|
||||
),
|
||||
Effect.andThen((result) =>
|
||||
|
|
@ -171,7 +177,7 @@ describe("EditTool", () => {
|
|||
return Effect.promise(() => fs.writeFile(target, "before")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(active.path, (registry) =>
|
||||
registry.execute(call({ path: target, oldString: "before", newString: "after" })),
|
||||
executeTool(registry, call({ path: target, oldString: "before", newString: "after" })),
|
||||
),
|
||||
),
|
||||
Effect.andThen((result) =>
|
||||
|
|
@ -202,7 +208,7 @@ describe("EditTool", () => {
|
|||
denyAction = "external_directory"
|
||||
expect(
|
||||
yield* withTool(active.path, (registry) =>
|
||||
registry.execute(call({ path: external, oldString: "before", newString: "after" })),
|
||||
executeTool(registry, call({ path: external, oldString: "before", newString: "after" })),
|
||||
),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
|
|
@ -216,7 +222,7 @@ describe("EditTool", () => {
|
|||
denyAction = "edit"
|
||||
expect(
|
||||
yield* withTool(active.path, (registry) =>
|
||||
registry.execute(call({ path: external, oldString: "before", newString: "after" })),
|
||||
executeTool(registry, call({ path: external, oldString: "before", newString: "after" })),
|
||||
),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
|
|
@ -245,10 +251,12 @@ describe("EditTool", () => {
|
|||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const matching = yield* registry.execute(
|
||||
const matching = yield* executeTool(
|
||||
registry,
|
||||
call({ path: "secret.txt", oldString: "secret content", newString: "replacement" }),
|
||||
)
|
||||
const missing = yield* registry.execute(
|
||||
const missing = yield* executeTool(
|
||||
registry,
|
||||
call({ path: "secret.txt", oldString: "not present", newString: "replacement" }),
|
||||
)
|
||||
|
||||
|
|
@ -277,26 +285,26 @@ describe("EditTool", () => {
|
|||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* registry.execute(call({ path: "matches.txt", oldString: "same", newString: "same" })),
|
||||
yield* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "same" })),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value: "No changes to apply: oldString and newString are identical.",
|
||||
})
|
||||
expect(
|
||||
yield* registry.execute(call({ path: "matches.txt", oldString: "", newString: "after" })),
|
||||
yield* executeTool(registry, call({ path: "matches.txt", oldString: "", newString: "after" })),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value: "oldString must not be empty. Use write to create or overwrite a file.",
|
||||
})
|
||||
expect(
|
||||
yield* registry.execute(call({ path: "matches.txt", oldString: "missing", newString: "after" })),
|
||||
yield* executeTool(registry, call({ path: "matches.txt", oldString: "missing", newString: "after" })),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value:
|
||||
"Could not find oldString in the file. It must match exactly, including whitespace and indentation.",
|
||||
})
|
||||
expect(
|
||||
yield* registry.execute(call({ path: "matches.txt", oldString: "same", newString: "after" })),
|
||||
yield* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "after" })),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value:
|
||||
|
|
@ -321,7 +329,7 @@ describe("EditTool", () => {
|
|||
return Effect.promise(() => fs.writeFile(target, "same same same")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
registry.settle(call({ path: "all.txt", oldString: "same", newString: "after", replaceAll: true })),
|
||||
settleTool(registry, call({ path: "all.txt", oldString: "same", newString: "after", replaceAll: true })),
|
||||
),
|
||||
),
|
||||
Effect.andThen((settled) =>
|
||||
|
|
@ -346,7 +354,7 @@ describe("EditTool", () => {
|
|||
return Effect.promise(() => fs.writeFile(target, "\uFEFFbefore\r\nrest\r\n")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
registry.execute(call({ path: "windows.txt", oldString: "before\nrest", newString: "after\nrest" })),
|
||||
executeTool(registry, call({ path: "windows.txt", oldString: "before\nrest", newString: "after\nrest" })),
|
||||
),
|
||||
),
|
||||
Effect.andThen(() => Effect.promise(() => fs.readFile(target, "utf8"))),
|
||||
|
|
@ -367,7 +375,7 @@ describe("EditTool", () => {
|
|||
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
registry.execute(call({ path: "concurrent.txt", oldString: "before", newString: "after" })),
|
||||
executeTool(registry, call({ path: "concurrent.txt", oldString: "before", newString: "after" })),
|
||||
),
|
||||
),
|
||||
Effect.andThen((result) =>
|
||||
|
|
@ -390,7 +398,7 @@ describe("EditTool", () => {
|
|||
test("keeps the locked edit schema, semantics docstring, and deferred TODOs visible", async () => {
|
||||
const source = (await fs.readFile(new URL("../src/tool/edit.ts", import.meta.url), "utf8")).replaceAll("\r\n", "\n")
|
||||
const definition = await Effect.runPromise(
|
||||
withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => registry.definitions()),
|
||||
withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => toolDefinitions(registry)),
|
||||
)
|
||||
const schema = definition[0]?.inputSchema as { readonly properties?: Record<string, unknown> }
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
|||
import { GlobTool } from "@opencode-ai/core/tool/glob"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_glob_tool_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
|
|
@ -90,8 +91,9 @@ const reset = () => {
|
|||
result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false })
|
||||
}
|
||||
|
||||
const call = (input: typeof GlobTool.Parameters.Type, id = "call-glob") => ({
|
||||
const call = (input: typeof GlobTool.Input.Type, id = "call-glob") => ({
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call" as const, id, name: "glob", input },
|
||||
})
|
||||
|
||||
|
|
@ -99,7 +101,7 @@ describe("GlobTool", () => {
|
|||
it.effect("registers the glob definition", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
expect((yield* (yield* ToolRegistry.Service).definitions()).map((tool) => tool.name)).toEqual(["glob"])
|
||||
expect((yield* toolDefinitions(yield* ToolRegistry.Service)).map((tool) => tool.name)).toEqual(["glob"])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -108,11 +110,13 @@ describe("GlobTool", () => {
|
|||
reset()
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(yield* registry.execute(call({ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }))).toEqual({
|
||||
expect(
|
||||
yield* executeTool(registry, call({ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 })),
|
||||
).toEqual({
|
||||
type: "text",
|
||||
value: "No files found",
|
||||
})
|
||||
expect(assertions).toEqual([
|
||||
expect(assertions).toMatchObject([
|
||||
{
|
||||
sessionID,
|
||||
action: "glob",
|
||||
|
|
@ -131,7 +135,7 @@ describe("GlobTool", () => {
|
|||
reset()
|
||||
allow = false
|
||||
|
||||
expect(yield* (yield* ToolRegistry.Service).execute(call({ pattern: "*.secret" }))).toEqual({
|
||||
expect(yield* executeTool(yield* ToolRegistry.Service, call({ pattern: "*.secret" }))).toEqual({
|
||||
type: "error",
|
||||
value: "Unable to find files matching *.secret",
|
||||
})
|
||||
|
|
@ -155,7 +159,7 @@ describe("GlobTool", () => {
|
|||
partial: false,
|
||||
})
|
||||
|
||||
expect(yield* (yield* ToolRegistry.Service).settle(call({ pattern: "*.ts" }))).toEqual({
|
||||
expect(yield* settleTool(yield* ToolRegistry.Service, call({ pattern: "*.ts" }))).toEqual({
|
||||
result: { type: "text", value: "src/index.ts" },
|
||||
output: {
|
||||
structured: result,
|
||||
|
|
@ -181,11 +185,11 @@ describe("GlobTool", () => {
|
|||
partial: false,
|
||||
})
|
||||
|
||||
expect(yield* (yield* ToolRegistry.Service).execute(call({ pattern: "*.md", reference: "docs" }))).toEqual({
|
||||
expect(yield* executeTool(yield* ToolRegistry.Service, call({ pattern: "*.md", reference: "docs" }))).toEqual({
|
||||
type: "text",
|
||||
value: "docs:guide.md",
|
||||
})
|
||||
expect(assertions).toEqual([
|
||||
expect(assertions).toMatchObject([
|
||||
{
|
||||
sessionID,
|
||||
action: "glob",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect, Exit, Layer } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
|
|
@ -19,6 +19,7 @@ import { location } from "./fixture/location"
|
|||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it as runtimeIt } from "./lib/effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
const searches: LocationSearch.GrepInput[] = []
|
||||
|
|
@ -90,12 +91,20 @@ const sessionID = SessionV2.ID.make("ses_grep_tool_test")
|
|||
|
||||
const execute = (input: Record<string, unknown>) =>
|
||||
ToolRegistry.Service.use((registry) =>
|
||||
registry.execute({ sessionID, call: { type: "tool-call", id: "call-grep", name: "grep", input } }),
|
||||
executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-grep", name: "grep", input },
|
||||
}),
|
||||
)
|
||||
|
||||
const settle = (input: Record<string, unknown>) =>
|
||||
ToolRegistry.Service.use((registry) =>
|
||||
registry.settle({ sessionID, call: { type: "tool-call", id: "call-grep", name: "grep", input } }),
|
||||
settleTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-grep", name: "grep", input },
|
||||
}),
|
||||
)
|
||||
|
||||
const reset = () => {
|
||||
|
|
@ -142,10 +151,10 @@ function provideLive(directory: string, projectReferences = references({})) {
|
|||
}
|
||||
|
||||
describe("GrepTool", () => {
|
||||
it.effect("registers the grep contribution", () =>
|
||||
it.effect("registers grep", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
expect(yield* (yield* ToolRegistry.Service).definitions()).toMatchObject([{ name: "grep" }])
|
||||
expect(yield* toolDefinitions(yield* ToolRegistry.Service)).toMatchObject([{ name: "grep" }])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -155,7 +164,7 @@ describe("GrepTool", () => {
|
|||
const input = { pattern: "needle", path: "src", include: "*.ts", limit: 2 }
|
||||
|
||||
expect(yield* execute(input)).toEqual({ type: "text", value: "No files found" })
|
||||
expect(assertions).toEqual([
|
||||
expect(assertions).toMatchObject([
|
||||
{
|
||||
sessionID,
|
||||
action: "grep",
|
||||
|
|
@ -226,7 +235,7 @@ describe("GrepTool", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("returns a useful tool error for an invalid regex", () =>
|
||||
it.effect("preserves an unexpected search defect", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
searchFailure = new Ripgrep.InvalidPatternError({
|
||||
|
|
@ -234,10 +243,7 @@ describe("GrepTool", () => {
|
|||
message: "regex parse error: unclosed character class",
|
||||
})
|
||||
|
||||
expect(yield* execute({ pattern: "[" })).toEqual({
|
||||
type: "error",
|
||||
value: 'Invalid grep pattern "[": regex parse error: unclosed character class',
|
||||
})
|
||||
expect(Exit.isFailure(yield* execute({ pattern: "[" }).pipe(Effect.exit))).toBe(true)
|
||||
expect(searches).toEqual([{ pattern: "[" }])
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Cause, Effect, Exit, Fiber, Layer, Option } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
|
|
@ -43,35 +43,7 @@ const withStore = <A, E, R>(
|
|||
const it = testEffect(Layer.empty)
|
||||
|
||||
describe("ToolOutputStore", () => {
|
||||
it.live("returns under-limit text unchanged without writing a file", () =>
|
||||
withStore(({ store }) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* store.truncate({ sessionID, toolCallID: "call-short", content: "one\ntwo" })).toEqual({
|
||||
content: "one\ntwo",
|
||||
truncated: false,
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("stores full output at an absolute managed path", () =>
|
||||
withStore(({ root, store, fs }) =>
|
||||
Effect.gen(function* () {
|
||||
const content = "HEAD-" + "x".repeat(500) + "-TAIL"
|
||||
const result = yield* store.truncate({ sessionID, toolCallID: "call-large", content, maxBytes: 300 })
|
||||
expect(result.truncated).toBe(true)
|
||||
if (!result.truncated) throw new Error("expected truncation")
|
||||
expect(path.isAbsolute(result.outputPath)).toBe(true)
|
||||
expect(result.outputPath).toStartWith(path.join(root, "tool-output", "tool_"))
|
||||
expect(result.content).toContain(result.outputPath)
|
||||
expect(result.content).toContain("HEAD-")
|
||||
expect(result.content).toContain("-TAIL")
|
||||
expect(yield* fs.readFileString(result.outputPath)).toBe(content)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("bounds aggregate text blocks with one managed file", () =>
|
||||
it.live("bounds the provider-facing text channel with one managed file", () =>
|
||||
withStore(({ store, fs }) =>
|
||||
Effect.gen(function* () {
|
||||
const first = "HEAD-" + "x".repeat(30_000)
|
||||
|
|
@ -89,7 +61,7 @@ describe("ToolOutputStore", () => {
|
|||
})
|
||||
expect(result.output.structured).toEqual({ kind: "report" })
|
||||
expect(result.outputPaths).toHaveLength(1)
|
||||
expect(yield* fs.readFileString(result.outputPaths[0]!)).toBe(`${first}\n\n${second}`)
|
||||
expect(yield* fs.readFileString(result.outputPaths[0])).toBe(first + second)
|
||||
if (result.output.content[0]?.type !== "text") throw new Error("expected text preview")
|
||||
expect(Buffer.byteLength(result.output.content[0].text)).toBeLessThanOrEqual(ToolOutputStore.MAX_BYTES)
|
||||
}),
|
||||
|
|
@ -101,37 +73,151 @@ describe("ToolOutputStore", () => {
|
|||
Effect.gen(function* () {
|
||||
const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) }
|
||||
const result = yield* store.bound({ sessionID, toolCallID: "call-json", output: { structured, content: [] } })
|
||||
expect(result.output.structured).toBe(structured)
|
||||
expect(result.output.structured).toEqual(structured)
|
||||
expect(result.outputPaths).toHaveLength(1)
|
||||
expect(yield* fs.readFileString(result.outputPaths[0]!)).toBe(JSON.stringify(structured))
|
||||
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured)
|
||||
expect(result.output.content).toHaveLength(1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("degrades to lossy bounded output when writing fails", () =>
|
||||
it.live("preserves native media and structured metadata without applying a settlement media limit", () =>
|
||||
withStore(({ store }) =>
|
||||
Effect.gen(function* () {
|
||||
const data = "a".repeat(6 * 1024 * 1024)
|
||||
const result = yield* store.bound({
|
||||
sessionID,
|
||||
toolCallID: "call-file",
|
||||
output: {
|
||||
structured: { caption: "pixel" },
|
||||
content: [{ type: "file", source: { type: "data", data }, mime: "image/png", name: "pixel.png" }],
|
||||
},
|
||||
})
|
||||
expect(result.outputPaths).toEqual([])
|
||||
expect(result.output.structured).toEqual({ caption: "pixel" })
|
||||
expect(result.output.content).toHaveLength(1)
|
||||
expect(result.output.content[0]).toEqual({
|
||||
type: "file",
|
||||
source: { type: "data", data },
|
||||
mime: "image/png",
|
||||
name: "pixel.png",
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves structured metadata and native media when bounding text", () =>
|
||||
withStore(({ store, fs }) =>
|
||||
Effect.gen(function* () {
|
||||
const text = "x".repeat(ToolOutputStore.MAX_BYTES + 1)
|
||||
const media = {
|
||||
type: "file" as const,
|
||||
source: { type: "data" as const, data: "aGVsbG8=" },
|
||||
mime: "image/png",
|
||||
name: "pixel.png",
|
||||
}
|
||||
const result = yield* store.bound({
|
||||
sessionID,
|
||||
toolCallID: "call-text-and-media",
|
||||
output: { structured: { caption: "pixel" }, content: [{ type: "text", text }, media] },
|
||||
})
|
||||
|
||||
expect(result.output.structured).toEqual({ caption: "pixel" })
|
||||
expect(result.output.content[1]).toEqual(media)
|
||||
expect(yield* fs.readFileString(result.outputPaths[0])).toBe(text)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not double-count structured data duplicated in projected text", () =>
|
||||
withStore(({ store }) =>
|
||||
Effect.gen(function* () {
|
||||
const text = "x".repeat(30_000)
|
||||
const output = { structured: { output: text }, content: [{ type: "text" as const, text }] }
|
||||
expect(yield* store.bound({ sessionID, toolCallID: "call-duplicated", output })).toEqual({
|
||||
output,
|
||||
outputPaths: [],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("fails oversized settlement when complete retention cannot be written", () =>
|
||||
withStore(({ root, store, fs }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* fs.writeFileString(path.join(root, "tool-output"), "not a directory")
|
||||
const result = yield* store.bound({
|
||||
sessionID,
|
||||
toolCallID: "call-lossy",
|
||||
output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
|
||||
})
|
||||
expect(result.outputPaths).toEqual([])
|
||||
if (result.output.content[0]?.type !== "text") throw new Error("expected text preview")
|
||||
expect(result.output.content[0].text).toContain("could not be retained")
|
||||
const exit = yield* store
|
||||
.bound({
|
||||
sessionID,
|
||||
toolCallID: "call-lossy",
|
||||
output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
|
||||
})
|
||||
.pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit))
|
||||
expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))?._tag).toBe("ToolOutputStore.StorageError")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not encode ignored structured metadata when projected content exists", () =>
|
||||
withStore(({ store }) =>
|
||||
Effect.gen(function* () {
|
||||
const output = { structured: { value: 1n }, content: [{ type: "text" as const, text: "readable text" }] }
|
||||
expect(yield* store.bound({ sessionID, toolCallID: "call-unencodable", output })).toEqual({
|
||||
output,
|
||||
outputPaths: [],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves interruption while retaining complete output", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.promise(() => tmpdir())
|
||||
const blockedFilesystem = Layer.effect(
|
||||
FSUtil.Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
return FSUtil.Service.of({
|
||||
...fs,
|
||||
ensureDir: () => Effect.void,
|
||||
writeFileString: () => Effect.never,
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(FSUtil.defaultLayer))
|
||||
const store = ToolOutputStore.layer.pipe(
|
||||
Layer.provide(blockedFilesystem),
|
||||
Layer.provide(Global.layerWith({ data: root.path })),
|
||||
)
|
||||
const exit = yield* Effect.gen(function* () {
|
||||
const service = yield* ToolOutputStore.Service
|
||||
const fiber = yield* service
|
||||
.bound({
|
||||
sessionID,
|
||||
toolCallID: "call-interrupted",
|
||||
output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
|
||||
})
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Fiber.interrupt(fiber)
|
||||
return yield* Fiber.await(fiber)
|
||||
}).pipe(Effect.provide(store))
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBe(true)
|
||||
yield* Effect.promise(() => root[Symbol.asyncDispose]())
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("honors configured limits", () =>
|
||||
withStore(
|
||||
({ store }) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* store.limits()).toEqual({ maxLines: 2, maxBytes: 1_000 })
|
||||
expect(
|
||||
(yield* store.truncate({ sessionID, toolCallID: "call-config", content: "one\ntwo\nthree" })).truncated,
|
||||
).toBe(true)
|
||||
const result = yield* store.bound({
|
||||
sessionID,
|
||||
toolCallID: "call-config",
|
||||
output: { structured: {}, content: [{ type: "text", text: "one\ntwo\nthree" }] },
|
||||
})
|
||||
expect(result.outputPaths).toHaveLength(1)
|
||||
}),
|
||||
new Config.Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
|
||||
),
|
||||
|
|
@ -140,9 +226,12 @@ describe("ToolOutputStore", () => {
|
|||
it.live("cleans expired managed files and preserves unrelated files", () =>
|
||||
withStore(({ root, store, fs }) =>
|
||||
Effect.gen(function* () {
|
||||
const old = yield* store.write({ sessionID, toolCallID: "old", content: "old" })
|
||||
const recent = yield* store.write({ sessionID, toolCallID: "recent", content: "recent" })
|
||||
const old = path.join(root, "tool-output", "tool_old")
|
||||
const recent = path.join(root, "tool-output", "tool_recent")
|
||||
const unrelated = path.join(root, "tool-output", "keep.txt")
|
||||
yield* fs.ensureDir(path.join(root, "tool-output"))
|
||||
yield* fs.writeFileString(old, "old")
|
||||
yield* fs.writeFileString(recent, "recent")
|
||||
yield* fs.writeFileString(unrelated, "keep")
|
||||
const expired = new Date(Date.now() - 8 * 24 * 60 * 60 * 1_000)
|
||||
yield* fs.utimes(old, expired, expired)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
|||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { QuestionTool } from "@opencode-ai/core/tool/question"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_question_tool_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
|
|
@ -40,7 +41,7 @@ const question = Layer.succeed(
|
|||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const tool = QuestionTool.layer.pipe(Layer.provide(registry), Layer.provide(question))
|
||||
const tool = QuestionTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(question))
|
||||
const it = testEffect(Layer.mergeAll(permission, registry, question, tool))
|
||||
|
||||
describe("QuestionTool", () => {
|
||||
|
|
@ -50,10 +51,11 @@ describe("QuestionTool", () => {
|
|||
deny = true
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).toEqual([])
|
||||
expect(yield* toolDefinitions(registry, [{ action: "question", resource: "*", effect: "deny" }])).toEqual([])
|
||||
expect(
|
||||
yield* registry.settle({
|
||||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } },
|
||||
}),
|
||||
).toEqual({ result: { type: "error", value: "Permission denied: question" } })
|
||||
|
|
@ -82,10 +84,11 @@ describe("QuestionTool", () => {
|
|||
},
|
||||
]
|
||||
|
||||
expect((yield* registry.definitions()).map((definition) => definition.name)).toEqual(["question"])
|
||||
expect((yield* toolDefinitions(registry)).map((definition) => definition.name)).toEqual(["question"])
|
||||
expect(
|
||||
yield* registry.settle({
|
||||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-question", name: "question", input: { questions } },
|
||||
}),
|
||||
).toEqual({
|
||||
|
|
@ -104,8 +107,12 @@ describe("QuestionTool", () => {
|
|||
],
|
||||
},
|
||||
})
|
||||
expect(assertions).toEqual([{ sessionID, action: "question", resources: ["*"] }])
|
||||
expect(capturedInput()).toEqual({ sessionID, questions, tool: undefined })
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
|
||||
expect(capturedInput()).toEqual({
|
||||
sessionID,
|
||||
questions,
|
||||
tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -116,11 +123,16 @@ describe("QuestionTool", () => {
|
|||
deny = false
|
||||
const registryService = yield* ToolRegistry.Service
|
||||
|
||||
yield* registryService.execute({
|
||||
yield* executeTool(registryService, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
|
||||
})
|
||||
expect(capturedInput()).toEqual({ sessionID, questions: [], tool: undefined })
|
||||
expect(capturedInput()).toEqual({
|
||||
sessionID,
|
||||
questions: [],
|
||||
tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -130,12 +142,11 @@ describe("QuestionTool", () => {
|
|||
reject = true
|
||||
deny = false
|
||||
const registryService = yield* ToolRegistry.Service
|
||||
const fiber = yield* registryService
|
||||
.execute({
|
||||
sessionID,
|
||||
call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
|
||||
})
|
||||
.pipe(Effect.forkScoped)
|
||||
const fiber = yield* executeTool(registryService, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect, Exit, Layer } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigAttachments } from "@opencode-ai/core/config/attachments"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ReadTool } from "@opencode-ai/core/tool/read"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
const readCalls: {
|
||||
|
|
@ -74,13 +76,29 @@ const permission = Layer.succeed(
|
|||
)
|
||||
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
|
||||
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(configEntries) }))
|
||||
const image = Image.layer.pipe(Layer.provide(config))
|
||||
const unavailableImage = Layer.succeed(
|
||||
Image.Service,
|
||||
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
|
||||
)
|
||||
const read = ReadTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(filesystem),
|
||||
Layer.provide(permission),
|
||||
Layer.provide(config),
|
||||
Layer.provide(image),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(registry, filesystem, permission, config, image, read))
|
||||
const unavailableRead = ReadTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(filesystem),
|
||||
Layer.provide(permission),
|
||||
Layer.provide(config),
|
||||
Layer.provide(unavailableImage),
|
||||
)
|
||||
const itWithoutResizer = testEffect(
|
||||
Layer.mergeAll(registry, filesystem, permission, config, unavailableImage, unavailableRead),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(registry, filesystem, permission, config, read))
|
||||
const sessionID = SessionV2.ID.make("ses_read_tool_test")
|
||||
|
||||
describe("ReadTool", () => {
|
||||
|
|
@ -100,11 +118,12 @@ describe("ReadTool", () => {
|
|||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(yield* registry.definitions()).toMatchObject([{ name: "read" }])
|
||||
expect(yield* registry.definitions([{ action: "read", resource: "*", effect: "deny" }])).toEqual([])
|
||||
expect(yield* toolDefinitions(registry)).toMatchObject([{ name: "read" }])
|
||||
expect(yield* toolDefinitions(registry, [{ action: "read", resource: "*", effect: "deny" }])).toEqual([])
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
|
||||
}),
|
||||
).toEqual({ type: "json", value: { type: "text", content: "hello", mime: "text/plain" } })
|
||||
|
|
@ -125,8 +144,9 @@ describe("ReadTool", () => {
|
|||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-image", name: "read", input: { path: "pixel.png" } },
|
||||
}),
|
||||
).toEqual({
|
||||
|
|
@ -138,12 +158,12 @@ describe("ReadTool", () => {
|
|||
})
|
||||
expect(readCalls).toEqual([{ input: { path: "pixel.png" }, page: {} }])
|
||||
|
||||
const settled = yield* registry.settle({
|
||||
const settled = yield* settleTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } },
|
||||
})
|
||||
expect(settled.output?.structured).toEqual({ type: "media", mime: "image/png" })
|
||||
expect(JSON.stringify(settled.output?.structured)).not.toContain(png)
|
||||
expect(settled.output?.structured).toMatchObject({ type: "binary", mime: "image/png", encoding: "base64" })
|
||||
expect(settled.output?.content).toMatchObject([
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", mime: "image/png", source: { type: "data", data: png } },
|
||||
|
|
@ -151,6 +171,64 @@ describe("ReadTool", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves a PNG above the generic text limit as native media", () =>
|
||||
Effect.gen(function* () {
|
||||
const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
|
||||
const pixels = Uint8Array.from({ length: 256 * 256 * 4 }, (_, index) => (index * 73 + (index >> 3)) % 256)
|
||||
const source = new photon.PhotonImage(pixels, 256, 256)
|
||||
const png = Buffer.from(source.get_bytes()).toString("base64")
|
||||
source.free()
|
||||
expect(Buffer.byteLength(png)).toBeGreaterThan(50 * 1024)
|
||||
readResult = new FileSystem.BinaryContent({
|
||||
type: "binary",
|
||||
content: png,
|
||||
encoding: "base64",
|
||||
mime: "image/png",
|
||||
})
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
const settled = yield* settleTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-large-image", name: "read", input: { path: "large.png" } },
|
||||
})
|
||||
|
||||
expect(settled.outputPaths).toBeUndefined()
|
||||
expect(settled.output?.structured).toMatchObject({ type: "binary", mime: "image/png", encoding: "base64" })
|
||||
expect(settled.result).toEqual({
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "media", mediaType: "image/png", data: png, filename: "large.png" },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
itWithoutResizer.effect("returns the original image when the resizer is unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||
readResult = new FileSystem.BinaryContent({
|
||||
type: "binary",
|
||||
content: png,
|
||||
encoding: "base64",
|
||||
mime: "image/png",
|
||||
})
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-image-fallback", name: "read", input: { path: "pixel.png" } },
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: "content",
|
||||
value: [{ type: "text" }, { type: "media", mediaType: "image/png", data: png }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects invalid image data returned by the filesystem", () =>
|
||||
Effect.gen(function* () {
|
||||
readResult = new FileSystem.BinaryContent({
|
||||
|
|
@ -162,8 +240,9 @@ describe("ReadTool", () => {
|
|||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-truncated-image", name: "read", input: { path: "truncated.png" } },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Image could not be decoded: truncated.png" })
|
||||
|
|
@ -193,8 +272,9 @@ describe("ReadTool", () => {
|
|||
}),
|
||||
]
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const result = yield* registry.execute({
|
||||
const result = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-wide-image", name: "read", input: { path: "wide.png" } },
|
||||
})
|
||||
|
||||
|
|
@ -224,8 +304,9 @@ describe("ReadTool", () => {
|
|||
}),
|
||||
]
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const result = yield* registry.execute({
|
||||
const result = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-resize-image", name: "read", input: { path: "wide.png" } },
|
||||
})
|
||||
|
||||
|
|
@ -261,8 +342,9 @@ describe("ReadTool", () => {
|
|||
}),
|
||||
]
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const result = yield* registry.execute({
|
||||
const result = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-max-bytes", name: "read", input: { path: "pixel.png" } },
|
||||
})
|
||||
|
||||
|
|
@ -283,8 +365,9 @@ describe("ReadTool", () => {
|
|||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-disguised-image", name: "read", input: { path: "pixel.bin" } },
|
||||
}),
|
||||
).toMatchObject({
|
||||
|
|
@ -294,22 +377,25 @@ describe("ReadTool", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves unsupported binary errors from the filesystem", () =>
|
||||
it.effect("preserves unexpected filesystem defects", () =>
|
||||
Effect.gen(function* () {
|
||||
readFailure = new FileSystem.BinaryFileError("archive.dat")
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
sessionID,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-binary",
|
||||
name: "read",
|
||||
input: { path: "archive.dat", offset: 2, limit: 1 },
|
||||
},
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" })
|
||||
Exit.isFailure(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-binary",
|
||||
name: "read",
|
||||
input: { path: "archive.dat", offset: 2, limit: 1 },
|
||||
},
|
||||
}).pipe(Effect.exit),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(readCalls).toEqual([
|
||||
{ input: { path: "archive.dat", offset: 2, limit: 1 }, page: { offset: 2, limit: 1 } },
|
||||
])
|
||||
|
|
@ -322,8 +408,9 @@ describe("ReadTool", () => {
|
|||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Unable to read README.md" })
|
||||
|
|
@ -337,8 +424,9 @@ describe("ReadTool", () => {
|
|||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-read-directory",
|
||||
|
|
@ -359,8 +447,9 @@ describe("ReadTool", () => {
|
|||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Unable to read src" })
|
||||
|
|
@ -372,8 +461,9 @@ describe("ReadTool", () => {
|
|||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
yield* registry.execute({
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md", reference: "docs" } },
|
||||
})
|
||||
|
||||
|
|
@ -381,17 +471,20 @@ describe("ReadTool", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("settles missing files as typed tool errors", () =>
|
||||
it.effect("preserves unexpected resolution defects", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
resolveFailure = new Error("missing")
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
sessionID,
|
||||
call: { type: "tool-call", id: "call-missing", name: "read", input: { path: "missing.txt" } },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Unable to read missing.txt" })
|
||||
Exit.isFailure(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-missing", name: "read", input: { path: "missing.txt" } },
|
||||
}).pipe(Effect.exit),
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
expect(readCalls).toEqual([])
|
||||
}),
|
||||
|
|
@ -410,8 +503,9 @@ describe("ReadTool", () => {
|
|||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-large",
|
||||
|
|
@ -438,8 +532,9 @@ describe("ReadTool", () => {
|
|||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-direct-binary", name: "read", input: { path: "late-binary" } },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Cannot read binary file: late-binary" })
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
|||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||
import { SkillTool } from "@opencode-ai/core/tool/skill"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_skill_tool_test")
|
||||
|
||||
|
|
@ -41,9 +41,6 @@ describe("SkillTool", () => {
|
|||
let current = [info]
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
let deny = false
|
||||
const truncations: ToolOutputStore.TruncateInput[] = []
|
||||
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
|
||||
Effect.succeed({ content: input.content, truncated: false })
|
||||
let bootWaited = false
|
||||
const boot = Layer.succeed(
|
||||
PluginBoot.Service,
|
||||
|
|
@ -77,75 +74,58 @@ describe("SkillTool", () => {
|
|||
}),
|
||||
)
|
||||
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
|
||||
const resources = Layer.succeed(
|
||||
ToolOutputStore.Service,
|
||||
ToolOutputStore.Service.of({
|
||||
limits: () => Effect.die("unused"),
|
||||
write: () => Effect.die("unused"),
|
||||
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
|
||||
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
|
||||
cleanup: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const tool = SkillTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(permission),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(boot),
|
||||
Layer.provide(skills),
|
||||
Layer.provide(resources),
|
||||
)
|
||||
const layer = Layer.mergeAll(permission, skills, registry, boot, resources, tool)
|
||||
const layer = Layer.mergeAll(permission, skills, registry, boot, tool)
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
expect(bootWaited).toBe(true)
|
||||
expect((yield* registry.definitions())[0]).toMatchObject({
|
||||
expect((yield* toolDefinitions(registry))[0]).toMatchObject({
|
||||
name: "skill",
|
||||
description: SkillTool.description,
|
||||
})
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-skill", name: "skill", input: { name: "effect" } },
|
||||
}),
|
||||
).toEqual({
|
||||
type: "text",
|
||||
value: SkillTool.toModelOutput(info, [reference]),
|
||||
})
|
||||
expect(truncations).toEqual([
|
||||
{ sessionID, toolCallID: "call-skill", content: SkillTool.toModelOutput(info, [reference]) },
|
||||
])
|
||||
truncate = (input) =>
|
||||
Effect.succeed({
|
||||
content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL",
|
||||
truncated: true,
|
||||
outputPath: "/tmp/tool-output/tool_opaque",
|
||||
})
|
||||
expect(
|
||||
yield* registry.settle({
|
||||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { name: "effect" } },
|
||||
}),
|
||||
).toMatchObject({
|
||||
result: { type: "text", value: expect.stringContaining("/tmp/tool-output/tool_opaque") },
|
||||
output: {
|
||||
structured: { truncated: true, outputPath: "/tmp/tool-output/tool_opaque" },
|
||||
},
|
||||
result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) },
|
||||
output: { structured: { name: "effect" } },
|
||||
})
|
||||
expect(assertions).toEqual([
|
||||
expect(assertions).toMatchObject([
|
||||
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
|
||||
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
|
||||
])
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { name: "missing" } },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Unable to load skill missing" })
|
||||
deny = true
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { name: "effect" } },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Unable to load skill effect" })
|
||||
|
|
@ -163,10 +143,10 @@ describe("SkillTool", () => {
|
|||
]),
|
||||
)
|
||||
current = [flat]
|
||||
truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { name: "public" } },
|
||||
}),
|
||||
).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) })
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { SessionTodo } from "@opencode-ai/core/session/todo"
|
|||
import { TodoWriteTool } from "@opencode-ai/core/tool/todowrite"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_todowrite_tool_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
|
|
@ -35,7 +36,7 @@ const database = Database.layerFromPath(":memory:")
|
|||
const events = EventV2.layer.pipe(Layer.provide(database))
|
||||
const todos = SessionTodo.layer.pipe(Layer.provide(database), Layer.provide(events))
|
||||
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
|
||||
const tool = TodoWriteTool.layer.pipe(Layer.provide(registry), Layer.provide(todos))
|
||||
const tool = TodoWriteTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(todos))
|
||||
const it = testEffect(Layer.mergeAll(database, events, todos, permission, registry, tool))
|
||||
|
||||
const setup = Effect.gen(function* () {
|
||||
|
|
@ -63,6 +64,7 @@ const setup = Effect.gen(function* () {
|
|||
|
||||
const call = (todos: ReadonlyArray<SessionTodo.Info>, id = "call-todowrite") => ({
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call" as const, id, name: TodoWriteTool.name, input: { todos } },
|
||||
})
|
||||
|
||||
|
|
@ -74,15 +76,15 @@ describe("TodoWriteTool", () => {
|
|||
const service = yield* SessionTodo.Service
|
||||
const todoList = [{ content: "Implement slice", status: "in_progress", priority: "high" }]
|
||||
|
||||
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual([TodoWriteTool.name])
|
||||
expect(yield* registry.settle(call(todoList))).toEqual({
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([TodoWriteTool.name])
|
||||
expect(yield* settleTool(registry, call(todoList))).toEqual({
|
||||
result: { type: "text", value: JSON.stringify(todoList, null, 2) },
|
||||
output: {
|
||||
structured: { todos: todoList },
|
||||
content: [{ type: "text", text: JSON.stringify(todoList, null, 2) }],
|
||||
},
|
||||
})
|
||||
expect(assertions).toEqual([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }])
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }])
|
||||
expect(yield* service.get(sessionID)).toEqual(todoList)
|
||||
}),
|
||||
)
|
||||
|
|
@ -95,12 +97,14 @@ describe("TodoWriteTool", () => {
|
|||
yield* service.update({ sessionID, todos: [{ content: "keep", status: "pending", priority: "low" }] })
|
||||
deny = true
|
||||
|
||||
expect(yield* registry.execute(call([{ content: "blocked", status: "completed", priority: "high" }]))).toEqual({
|
||||
expect(
|
||||
yield* executeTool(registry, call([{ content: "blocked", status: "completed", priority: "high" }])),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value: "Unable to update todos",
|
||||
})
|
||||
expect(yield* service.get(sessionID)).toEqual([{ content: "keep", status: "pending", priority: "low" }])
|
||||
expect(assertions).toEqual([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }])
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,19 +4,16 @@ import * as TestClock from "effect/testing/TestClock"
|
|||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { WebFetchTool } from "@opencode-ai/core/tool/webfetch"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_webfetch_test")
|
||||
const requests: Array<{ readonly url: string; readonly headers: Record<string, string> }> = []
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
const truncations: ToolOutputStore.TruncateInput[] = []
|
||||
let respond = (_request: HttpClientRequest.HttpClientRequest) =>
|
||||
Effect.succeed(new Response("hello", { headers: { "content-type": "text/plain" } }))
|
||||
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
|
||||
Effect.succeed({ content: input.content, truncated: false })
|
||||
|
||||
const http = Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
|
|
@ -38,42 +35,31 @@ const permission = Layer.succeed(
|
|||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const resources = Layer.succeed(
|
||||
ToolOutputStore.Service,
|
||||
ToolOutputStore.Service.of({
|
||||
limits: () => Effect.die("unused"),
|
||||
write: () => Effect.die("unused"),
|
||||
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
|
||||
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
|
||||
cleanup: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
|
||||
const webfetch = WebFetchTool.layer.pipe(Layer.provide(registry), Layer.provide(http), Layer.provide(resources))
|
||||
const it = testEffect(Layer.mergeAll(registry, permission, http, resources, webfetch))
|
||||
const webfetch = WebFetchTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(http))
|
||||
const it = testEffect(Layer.mergeAll(registry, permission, http, webfetch))
|
||||
const fetchWebfetch = WebFetchTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(permission),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(resources),
|
||||
)
|
||||
const live = testEffect(Layer.mergeAll(registry, permission, FetchHttpClient.layer, resources, fetchWebfetch))
|
||||
const live = testEffect(Layer.mergeAll(registry, permission, FetchHttpClient.layer, fetchWebfetch))
|
||||
|
||||
const reset = () => {
|
||||
requests.length = 0
|
||||
assertions.length = 0
|
||||
truncations.length = 0
|
||||
respond = () => Effect.succeed(new Response("hello", { headers: { "content-type": "text/plain" } }))
|
||||
truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
|
||||
}
|
||||
|
||||
const call = (input: typeof WebFetchTool.Parameters.Type, id = "call-webfetch") => ({
|
||||
const call = (input: typeof WebFetchTool.Input.Type, id = "call-webfetch") => ({
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call" as const, id, name: "webfetch", input },
|
||||
})
|
||||
|
||||
describe("WebFetchTool helpers", () => {
|
||||
test("defaults format and rejects invalid timeout controls", () => {
|
||||
const decode = Schema.decodeUnknownSync(WebFetchTool.Parameters)
|
||||
const decode = Schema.decodeUnknownSync(WebFetchTool.Input)
|
||||
expect(decode({ url: "https://example.com" })).toEqual({ url: "https://example.com", format: "markdown" })
|
||||
expect(() => decode({ url: "https://example.com", timeout: 0 })).toThrow()
|
||||
expect(() => decode({ url: "https://example.com", timeout: WebFetchTool.MAX_TIMEOUT_SECONDS + 1 })).toThrow()
|
||||
|
|
@ -86,22 +72,22 @@ describe("WebFetchTool helpers", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("WebFetchTool contribution", () => {
|
||||
describe("WebFetchTool registration", () => {
|
||||
it.effect("registers and fetches an ordinary hostname HTTP URL without rewriting it", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const url = "http://example.com/public"
|
||||
|
||||
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["webfetch"])
|
||||
expect(yield* registry.settle(call({ url, format: "text", timeout: 4 }))).toEqual({
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["webfetch"])
|
||||
expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
|
||||
result: { type: "text", value: "hello" },
|
||||
output: {
|
||||
structured: { url, contentType: "text/plain", format: "text", output: "hello", truncated: false },
|
||||
structured: { url, contentType: "text/plain", format: "text", output: "hello" },
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
})
|
||||
expect(assertions).toEqual([
|
||||
expect(assertions).toMatchObject([
|
||||
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } },
|
||||
])
|
||||
expect(requests).toMatchObject([{ url, headers: { accept: expect.stringContaining("text/plain;q=1.0") } }])
|
||||
|
|
@ -114,11 +100,11 @@ describe("WebFetchTool contribution", () => {
|
|||
const registry = yield* ToolRegistry.Service
|
||||
const url = "http://localhost/private"
|
||||
|
||||
expect(yield* registry.execute(call({ url, format: "text" }))).toEqual({
|
||||
expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({
|
||||
type: "text",
|
||||
value: "hello",
|
||||
})
|
||||
expect(assertions).toEqual([
|
||||
expect(assertions).toMatchObject([
|
||||
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
|
||||
])
|
||||
expect(requests.map((request) => request.url)).toEqual([url])
|
||||
|
|
@ -142,8 +128,11 @@ describe("WebFetchTool contribution", () => {
|
|||
const registry = yield* ToolRegistry.Service
|
||||
const url = new URL("/redirect", server.url).toString()
|
||||
|
||||
expect(yield* registry.execute(call({ url, format: "text" }))).toEqual({ type: "text", value: "redirected" })
|
||||
expect(assertions).toEqual([
|
||||
expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({
|
||||
type: "text",
|
||||
value: "redirected",
|
||||
})
|
||||
expect(assertions).toMatchObject([
|
||||
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
|
||||
])
|
||||
}),
|
||||
|
|
@ -156,7 +145,7 @@ describe("WebFetchTool contribution", () => {
|
|||
reset()
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(yield* registry.execute(call({ url: "file:///etc/passwd", format: "text" }))).toEqual({
|
||||
expect(yield* executeTool(registry, call({ url: "file:///etc/passwd", format: "text" }))).toEqual({
|
||||
type: "error",
|
||||
value: "Unable to fetch file:///etc/passwd",
|
||||
})
|
||||
|
|
@ -176,41 +165,17 @@ describe("WebFetchTool contribution", () => {
|
|||
)
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(yield* registry.execute(call({ url: "https://1.1.1.1", format: "markdown" }))).toEqual({
|
||||
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "markdown" }))).toEqual({
|
||||
type: "text",
|
||||
value: "# Hello\n\nworld",
|
||||
})
|
||||
expect(yield* registry.execute(call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
|
||||
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
|
||||
type: "text",
|
||||
value: "Helloworld",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("exposes managed overflow through a path", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
truncate = (input) =>
|
||||
Effect.succeed({
|
||||
content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL",
|
||||
truncated: true,
|
||||
outputPath: "/tmp/tool-output/tool_opaque",
|
||||
})
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const settled = yield* registry.settle(call({ url: "https://1.1.1.1", format: "html" }, "call-overflow"))
|
||||
|
||||
expect(settled.result).toMatchObject({
|
||||
type: "text",
|
||||
value: expect.stringContaining("/tmp/tool-output/tool_opaque"),
|
||||
})
|
||||
expect(settled.output?.structured).toMatchObject({
|
||||
truncated: true,
|
||||
outputPath: "/tmp/tool-output/tool_opaque",
|
||||
})
|
||||
expect(truncations).toEqual([{ sessionID, toolCallID: "call-overflow", content: "hello", mime: "text/html" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects declared and streamed oversized bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
|
|
@ -221,7 +186,7 @@ describe("WebFetchTool contribution", () => {
|
|||
headers: { "content-type": "text/plain", "content-length": String(WebFetchTool.MAX_RESPONSE_BYTES + 1) },
|
||||
}),
|
||||
)
|
||||
expect(yield* registry.execute(call({ url: "https://1.1.1.1/declared", format: "text" }))).toEqual({
|
||||
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/declared", format: "text" }))).toEqual({
|
||||
type: "error",
|
||||
value: "Unable to fetch https://1.1.1.1/declared",
|
||||
})
|
||||
|
|
@ -230,7 +195,7 @@ describe("WebFetchTool contribution", () => {
|
|||
Effect.succeed(
|
||||
new Response("x".repeat(WebFetchTool.MAX_RESPONSE_BYTES + 1), { headers: { "content-type": "text/plain" } }),
|
||||
)
|
||||
expect(yield* registry.execute(call({ url: "https://1.1.1.1/streamed", format: "text" }))).toEqual({
|
||||
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/streamed", format: "text" }))).toEqual({
|
||||
type: "error",
|
||||
value: "Unable to fetch https://1.1.1.1/streamed",
|
||||
})
|
||||
|
|
@ -242,17 +207,16 @@ describe("WebFetchTool contribution", () => {
|
|||
reset()
|
||||
const registry = yield* ToolRegistry.Service
|
||||
respond = () => Effect.succeed(new Response("png", { headers: { "content-type": "image/png" } }))
|
||||
expect(yield* registry.execute(call({ url: "https://1.1.1.1/image", format: "html" }))).toEqual({
|
||||
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/image", format: "html" }))).toEqual({
|
||||
type: "error",
|
||||
value: "Unable to fetch https://1.1.1.1/image",
|
||||
})
|
||||
|
||||
respond = () => Effect.succeed(new Response("pdf", { headers: { "content-type": "application/pdf" } }))
|
||||
expect(yield* registry.execute(call({ url: "https://1.1.1.1/file", format: "html" }))).toEqual({
|
||||
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/file", format: "html" }))).toEqual({
|
||||
type: "error",
|
||||
value: "Unable to fetch https://1.1.1.1/file",
|
||||
})
|
||||
expect(truncations).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -268,7 +232,7 @@ describe("WebFetchTool contribution", () => {
|
|||
)
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(yield* registry.execute(call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
|
||||
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
|
||||
type: "text",
|
||||
value: "ok",
|
||||
})
|
||||
|
|
@ -283,9 +247,10 @@ describe("WebFetchTool contribution", () => {
|
|||
reset()
|
||||
respond = () => Effect.never
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const fiber = yield* registry
|
||||
.execute(call({ url: "https://1.1.1.1/slow", format: "text", timeout: 1 }))
|
||||
.pipe(Effect.forkChild)
|
||||
const fiber = yield* executeTool(
|
||||
registry,
|
||||
call({ url: "https://1.1.1.1/slow", format: "text", timeout: 1 }),
|
||||
).pipe(Effect.forkChild)
|
||||
yield* TestClock.adjust(Duration.seconds(1))
|
||||
|
||||
expect(yield* Fiber.join(fiber)).toEqual({ type: "error", value: "Unable to fetch https://1.1.1.1/slow" })
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
|
|||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_websearch_test")
|
||||
const payload = (text: string) =>
|
||||
|
|
@ -18,7 +18,7 @@ const payload = (text: string) =>
|
|||
|
||||
describe("WebSearchTool provider selection", () => {
|
||||
test("rejects out-of-range numeric controls", () => {
|
||||
const decode = Schema.decodeUnknownSync(WebSearchTool.Parameters)
|
||||
const decode = Schema.decodeUnknownSync(WebSearchTool.Input)
|
||||
expect(() => decode({ query: "x", numResults: 0 })).toThrow()
|
||||
expect(() => decode({ query: "x", numResults: WebSearchTool.MAX_NUM_RESULTS + 1 })).toThrow()
|
||||
expect(() => decode({ query: "x", contextMaxCharacters: WebSearchTool.MAX_CONTEXT_CHARACTERS + 1 })).toThrow()
|
||||
|
|
@ -65,11 +65,8 @@ interface Request {
|
|||
|
||||
const requests: Request[] = []
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
const truncations: ToolOutputStore.TruncateInput[] = []
|
||||
let responseBody = payload("search results")
|
||||
let config: WebSearchTool.Config = { enableExa: false, enableParallel: false }
|
||||
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
|
||||
Effect.succeed({ content: input.content, truncated: false })
|
||||
|
||||
const http = Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
|
|
@ -117,40 +114,28 @@ const websearchConfig = Layer.succeed(
|
|||
},
|
||||
}),
|
||||
)
|
||||
const resources = Layer.succeed(
|
||||
ToolOutputStore.Service,
|
||||
ToolOutputStore.Service.of({
|
||||
limits: () => Effect.die("unused"),
|
||||
write: () => Effect.die("unused"),
|
||||
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
|
||||
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
|
||||
cleanup: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const websearch = WebSearchTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(permission),
|
||||
Layer.provide(http),
|
||||
Layer.provide(websearchConfig),
|
||||
Layer.provide(resources),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(registry, permission, http, websearchConfig, resources, websearch))
|
||||
const it = testEffect(Layer.mergeAll(registry, permission, http, websearchConfig, websearch))
|
||||
|
||||
describe("WebSearchTool contribution", () => {
|
||||
describe("WebSearchTool registration", () => {
|
||||
it.effect("registers websearch, asserts query permission, and calls Exa", () =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
assertions.length = 0
|
||||
truncations.length = 0
|
||||
truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
|
||||
responseBody = payload("exa results")
|
||||
config = { provider: "exa", enableExa: false, enableParallel: false }
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["websearch"])
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch"])
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-exa",
|
||||
|
|
@ -165,7 +150,7 @@ describe("WebSearchTool contribution", () => {
|
|||
},
|
||||
}),
|
||||
).toEqual({ type: "text", value: "exa results" })
|
||||
expect(assertions).toEqual([
|
||||
expect(assertions).toMatchObject([
|
||||
{
|
||||
sessionID,
|
||||
action: "websearch",
|
||||
|
|
@ -213,8 +198,9 @@ describe("WebSearchTool contribution", () => {
|
|||
config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" }
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
const settled = yield* registry.settle({
|
||||
const settled = yield* settleTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
|
||||
})
|
||||
|
||||
|
|
@ -235,7 +221,7 @@ describe("WebSearchTool contribution", () => {
|
|||
expect(settled).toEqual({
|
||||
result: { type: "text", value: "parallel results" },
|
||||
output: {
|
||||
structured: { provider: "parallel", text: "parallel results", truncated: false },
|
||||
structured: { provider: "parallel", text: "parallel results" },
|
||||
content: [{ type: "text", text: "parallel results" }],
|
||||
},
|
||||
})
|
||||
|
|
@ -251,8 +237,9 @@ describe("WebSearchTool contribution", () => {
|
|||
config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" }
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
const settled = yield* registry.settle({
|
||||
const settled = yield* settleTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } },
|
||||
})
|
||||
|
||||
|
|
@ -270,47 +257,15 @@ describe("WebSearchTool contribution", () => {
|
|||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-empty", name: "websearch", input: { query: "nothing" } },
|
||||
}),
|
||||
).toEqual({ type: "text", value: WebSearchTool.NO_RESULTS })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("exposes managed overflow through typed structured output", () =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
assertions.length = 0
|
||||
truncations.length = 0
|
||||
responseBody = payload("full search results")
|
||||
config = { provider: "exa", enableExa: false, enableParallel: false }
|
||||
truncate = (input) =>
|
||||
Effect.succeed({
|
||||
content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL",
|
||||
truncated: true,
|
||||
outputPath: "/tmp/tool-output/tool_opaque",
|
||||
})
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
const settled = yield* registry.settle({
|
||||
sessionID,
|
||||
call: { type: "tool-call", id: "call-overflow", name: "websearch", input: { query: "verbose" } },
|
||||
})
|
||||
|
||||
expect(settled.result).toMatchObject({
|
||||
type: "text",
|
||||
value: expect.stringContaining("/tmp/tool-output/tool_opaque"),
|
||||
})
|
||||
expect(settled.output?.structured).toMatchObject({
|
||||
provider: "exa",
|
||||
truncated: true,
|
||||
outputPath: "/tmp/tool-output/tool_opaque",
|
||||
})
|
||||
expect(truncations).toEqual([{ sessionID, toolCallID: "call-overflow", content: "full search results" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects oversized MCP response bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
requests.length = 0
|
||||
|
|
@ -320,8 +275,9 @@ describe("WebSearchTool contribution", () => {
|
|||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Unable to search the web for too much" })
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { WriteTool } from "@opencode-ai/core/tool/write"
|
|||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_write_tool_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
|
|
@ -64,14 +65,20 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
|
|||
const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
|
||||
const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
|
||||
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
|
||||
const write = WriteTool.layer.pipe(Layer.provide(registry), Layer.provide(resolution), Layer.provide(mutation))
|
||||
const write = WriteTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(permission),
|
||||
Layer.provide(resolution),
|
||||
Layer.provide(mutation),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
return yield* body(yield* ToolRegistry.Service)
|
||||
}).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, write)))
|
||||
}
|
||||
|
||||
const call = (input: typeof WriteTool.Parameters.Type, id = "call-write") => ({
|
||||
const call = (input: typeof WriteTool.Input.Type, id = "call-write") => ({
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call" as const, id, name: "write", input },
|
||||
})
|
||||
|
||||
|
|
@ -85,8 +92,8 @@ describe("WriteTool", () => {
|
|||
reset()
|
||||
return withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["write"])
|
||||
const settled = yield* registry.settle(call({ path: "src/new.txt", content: "created" }))
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write"])
|
||||
const settled = yield* settleTool(registry, call({ path: "src/new.txt", content: "created" }))
|
||||
expect(settled).toEqual({
|
||||
result: { type: "text", value: "Created file successfully: src/new.txt" },
|
||||
output: {
|
||||
|
|
@ -102,7 +109,7 @@ describe("WriteTool", () => {
|
|||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe(
|
||||
"created",
|
||||
)
|
||||
expect(assertions).toEqual([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }])
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }])
|
||||
expect(writes).toEqual([path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt")])
|
||||
}),
|
||||
)
|
||||
|
|
@ -118,7 +125,7 @@ describe("WriteTool", () => {
|
|||
reset()
|
||||
return Effect.promise(() => fs.writeFile(path.join(tmp.path, "existing.txt"), "before")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) => registry.settle(call({ path: "existing.txt", content: "after" }))),
|
||||
withTool(tmp.path, (registry) => settleTool(registry, call({ path: "existing.txt", content: "after" }))),
|
||||
),
|
||||
Effect.andThen((settled) =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -149,8 +156,11 @@ describe("WriteTool", () => {
|
|||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
yield* registry.settle(call({ path: "preserved.txt", content: "after" }, "call-preserved"))
|
||||
yield* registry.settle(call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"))
|
||||
yield* settleTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved"))
|
||||
yield* settleTool(
|
||||
registry,
|
||||
call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"),
|
||||
)
|
||||
|
||||
expect(yield* Effect.promise(() => fs.readFile(preserved, "utf8"))).toBe("\uFEFFafter")
|
||||
expect(yield* Effect.promise(() => fs.readFile(deduplicated, "utf8"))).toBe("\uFEFFafter")
|
||||
|
|
@ -169,7 +179,7 @@ describe("WriteTool", () => {
|
|||
(tmp) => {
|
||||
reset()
|
||||
const target = path.join(tmp.path, "absolute.txt")
|
||||
return withTool(tmp.path, (registry) => registry.execute(call({ path: target, content: "inside" }))).pipe(
|
||||
return withTool(tmp.path, (registry) => executeTool(registry, call({ path: target, content: "inside" }))).pipe(
|
||||
Effect.andThen((result) =>
|
||||
Effect.gen(function* () {
|
||||
expect(result).toEqual({ type: "text", value: "Created file successfully: absolute.txt" })
|
||||
|
|
@ -189,7 +199,9 @@ describe("WriteTool", () => {
|
|||
([active, outside]) => {
|
||||
reset()
|
||||
const target = path.join(outside.path, "external.txt")
|
||||
return withTool(active.path, (registry) => registry.settle(call({ path: target, content: "external" }))).pipe(
|
||||
return withTool(active.path, (registry) =>
|
||||
settleTool(registry, call({ path: target, content: "external" })),
|
||||
).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.gen(function* () {
|
||||
const canonicalTarget = path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "external.txt")
|
||||
|
|
@ -227,7 +239,9 @@ describe("WriteTool", () => {
|
|||
reset()
|
||||
denyAction = "external_directory"
|
||||
expect(
|
||||
yield* withTool(active.path, (registry) => registry.execute(call({ path: external, content: "blocked" }))),
|
||||
yield* withTool(active.path, (registry) =>
|
||||
executeTool(registry, call({ path: external, content: "blocked" })),
|
||||
),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value: `Unable to write ${external}`,
|
||||
|
|
@ -239,7 +253,7 @@ describe("WriteTool", () => {
|
|||
denyAction = "edit"
|
||||
expect(
|
||||
yield* withTool(active.path, (registry) =>
|
||||
registry.execute(call({ path: "denied.txt", content: "blocked" })),
|
||||
executeTool(registry, call({ path: "denied.txt", content: "blocked" })),
|
||||
),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
|
|
@ -259,7 +273,7 @@ describe("WriteTool", () => {
|
|||
test("keeps the locked write schema, semantics docstring, and deferred UX TODOs visible", async () => {
|
||||
const source = (await fs.readFile(new URL("../src/tool/write.ts", import.meta.url), "utf8")).replaceAll("\r\n", "\n")
|
||||
const definition = await Effect.runPromise(
|
||||
withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => registry.definitions()),
|
||||
withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => toolDefinitions(registry)),
|
||||
)
|
||||
const schema = definition[0]?.inputSchema as { readonly properties?: Record<string, unknown> }
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue