refactor(tools): unify tool APIs and result handling (#38367)
This commit is contained in:
parent
8cac010bac
commit
79c1544072
133 changed files with 3602 additions and 2770 deletions
|
|
@ -9,14 +9,16 @@ describe("CodeMode", () => {
|
|||
it.effect("owns registrations, execute, and catalog materialization", () =>
|
||||
Effect.gen(function* () {
|
||||
const codeMode = yield* CodeMode.Service
|
||||
yield* codeMode.register({
|
||||
echo: Tool.make({
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: ({ text }) => Effect.succeed(text),
|
||||
yield* codeMode.register(
|
||||
Tool.registrationEntries({
|
||||
echo: Tool.make({
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: ({ text }) => Effect.succeed({ output: text }),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
const materialized = yield* codeMode.materialize()
|
||||
expect(materialized.tool).toBeDefined()
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import addSessionForkMigration from "@opencode-ai/core/database/migration/202607
|
|||
import timeSuspendedMigration from "@opencode-ai/core/database/migration/20260709163752_time_suspended"
|
||||
import instructionSyncMigration from "@opencode-ai/core/database/migration/20260710025429_instruction_sync"
|
||||
import deleteToolProgressEventsMigration from "@opencode-ai/core/database/migration/20260722011141_delete_tool_progress_events"
|
||||
import canonicalToolResultsMigration from "@opencode-ai/core/database/migration/20260722170000_canonical_tool_results"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
|
|
@ -583,6 +584,208 @@ describe("DatabaseMigration", () => {
|
|||
)
|
||||
})
|
||||
|
||||
test("rewrites projected tool rows into the canonical result shape", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`,
|
||||
)
|
||||
yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, type text NOT NULL, data text NOT NULL)`)
|
||||
const assistant = {
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [
|
||||
{ type: "text", text: "before" },
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_content",
|
||||
name: "grep",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { pattern: "TODO" },
|
||||
content: [{ type: "text", text: "src/a.ts:1: TODO" }],
|
||||
structured: { value: [{ file: "src/a.ts", line: 1 }] },
|
||||
},
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_structured_only",
|
||||
name: "read",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { path: "README.md" },
|
||||
content: [],
|
||||
structured: { text: "hello" },
|
||||
},
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_hosted",
|
||||
name: "web_search",
|
||||
executed: true,
|
||||
providerResultState: { blockType: "web_search_tool_result" },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { query: "effect" },
|
||||
content: [],
|
||||
structured: {},
|
||||
result: { type: "json", value: [{ url: "https://example.com" }] },
|
||||
},
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_failed",
|
||||
name: "shell",
|
||||
state: {
|
||||
status: "error",
|
||||
input: { command: "sleep 99" },
|
||||
error: { type: "tool.execution", message: "timed out" },
|
||||
content: [{ type: "text", text: "partial output" }],
|
||||
structured: { truncated: false },
|
||||
result: { type: "error", value: "timed out" },
|
||||
},
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_running",
|
||||
name: "shell",
|
||||
state: {
|
||||
status: "running",
|
||||
input: { command: "sleep 1" },
|
||||
structured: { truncated: false },
|
||||
content: [{ type: "text", text: "tick" }],
|
||||
},
|
||||
time: { created: 1, ran: 2 },
|
||||
},
|
||||
],
|
||||
time: { created: 1 },
|
||||
}
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session_message VALUES ('msg_tools', 'ses_test', 'assistant', 1, 10, 11, ${JSON.stringify(assistant)})`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session_message VALUES ('msg_user', 'ses_test', 'user', 2, 12, 13, '{"text":"hi","time":{"created":1}}')`,
|
||||
)
|
||||
// A row that never decoded must be skipped, not fail the migration.
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session_message VALUES ('msg_corrupt', 'ses_test', 'assistant', 3, 14, 15, 'not json')`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO event VALUES ('evt_success', 'session.tool.success.1', ${JSON.stringify({
|
||||
sessionID: "ses_test",
|
||||
assistantMessageID: "msg_tools",
|
||||
callID: "call_hosted",
|
||||
structured: {},
|
||||
content: [],
|
||||
result: { type: "json", value: [{ url: "https://example.com" }] },
|
||||
executed: true,
|
||||
})})`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO event VALUES ('evt_failed', 'session.tool.failed.1', ${JSON.stringify({
|
||||
sessionID: "ses_test",
|
||||
assistantMessageID: "msg_tools",
|
||||
callID: "call_failed",
|
||||
error: { type: "tool.execution", message: "timed out" },
|
||||
metadata: { truncated: false },
|
||||
executed: false,
|
||||
})})`,
|
||||
)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [canonicalToolResultsMigration])
|
||||
|
||||
const row = yield* db.get<{ data: string }>(sql`SELECT data FROM session_message WHERE id = 'msg_tools'`)
|
||||
const migrated = JSON.parse(row!.data)
|
||||
// Every migrated row must decode with the current schema; reload hard-fails otherwise.
|
||||
Schema.decodeUnknownSync(SessionMessage.Info)({ ...migrated, id: "msg_tools", type: "assistant" })
|
||||
const states = new Map(
|
||||
migrated.content.flatMap((part: { type: string; id?: string }) =>
|
||||
part.type === "tool" ? [[part.id, part]] : [],
|
||||
),
|
||||
)
|
||||
expect(states.get("call_content")).toMatchObject({
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { pattern: "TODO" },
|
||||
content: [{ type: "text", text: "src/a.ts:1: TODO" }],
|
||||
// Old generic structured payloads survive as canonical metadata.
|
||||
metadata: { value: [{ file: "src/a.ts", line: 1 }] },
|
||||
},
|
||||
})
|
||||
expect((states.get("call_content") as { state: Record<string, unknown> }).state).not.toHaveProperty(
|
||||
"structured",
|
||||
)
|
||||
expect(states.get("call_structured_only")).toMatchObject({
|
||||
state: {
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: JSON.stringify({ text: "hello" }, null, 2) }],
|
||||
metadata: { text: "hello" },
|
||||
},
|
||||
})
|
||||
expect(states.get("call_hosted")).toMatchObject({
|
||||
executed: true,
|
||||
providerResultState: {
|
||||
blockType: "web_search_tool_result",
|
||||
result: [{ url: "https://example.com" }],
|
||||
},
|
||||
state: {
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: JSON.stringify([{ url: "https://example.com" }], null, 2) }],
|
||||
},
|
||||
})
|
||||
expect(states.get("call_failed")).toMatchObject({
|
||||
state: {
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: "timed out" },
|
||||
content: [{ type: "text", text: "partial output" }],
|
||||
metadata: { truncated: false },
|
||||
},
|
||||
})
|
||||
const failedState = (states.get("call_failed") as { state: Record<string, unknown> }).state
|
||||
expect(failedState).not.toHaveProperty("result")
|
||||
expect(failedState).not.toHaveProperty("structured")
|
||||
expect(states.get("call_running")).toMatchObject({
|
||||
state: {
|
||||
status: "running",
|
||||
metadata: { truncated: false },
|
||||
},
|
||||
})
|
||||
const event = yield* db.get<{ type: string; data: string }>(sql`SELECT type, data FROM event WHERE id = 'evt_success'`)
|
||||
expect(event!.type).toBe("session.tool.success.1")
|
||||
expect(JSON.parse(event!.data)).toEqual({
|
||||
sessionID: "ses_test",
|
||||
assistantMessageID: "msg_tools",
|
||||
callID: "call_hosted",
|
||||
structured: {},
|
||||
content: [],
|
||||
result: { type: "json", value: [{ url: "https://example.com" }] },
|
||||
executed: true,
|
||||
})
|
||||
const failedEvent = yield* db.get<{ type: string; data: string }>(sql`SELECT type, data FROM event WHERE id = 'evt_failed'`)
|
||||
expect(failedEvent!.type).toBe("session.tool.failed.1")
|
||||
expect(JSON.parse(failedEvent!.data)).toEqual({
|
||||
sessionID: "ses_test",
|
||||
assistantMessageID: "msg_tools",
|
||||
callID: "call_failed",
|
||||
error: { type: "tool.execution", message: "timed out" },
|
||||
metadata: { truncated: false },
|
||||
executed: false,
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT data FROM session_message WHERE id = 'msg_user'`)).toEqual({
|
||||
data: '{"text":"hi","time":{"created":1}}',
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT data FROM session_message WHERE id = 'msg_corrupt'`)).toEqual({
|
||||
data: "not json",
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("records the authoritative parent sequence on existing forks", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const toolIdentity = {
|
|||
}
|
||||
|
||||
export const toolDefinitions = (registry: ToolRegistry.Interface, permissions?: PermissionV2.Ruleset) =>
|
||||
registry.materialize(permissions).pipe(Effect.map((materialized) => materialized.definitions))
|
||||
registry.snapshot(permissions).pipe(Effect.map((toolSet) => toolSet.definitions))
|
||||
|
||||
export function waitForTool(
|
||||
registry: ToolRegistry.Interface,
|
||||
|
|
@ -35,7 +35,7 @@ export function waitForTool(
|
|||
/**
|
||||
* Registers a core tool plugin's tools against the real registry without booting the
|
||||
* full plugin host. Only the tool domain is live; focused tool tests exercise
|
||||
* registration, materialization, and settlement through the same path production uses.
|
||||
* registration, snapshots, and execution through the same path production uses.
|
||||
*/
|
||||
export const registerToolPlugin = <R>(plugin: {
|
||||
readonly id: string
|
||||
|
|
@ -52,7 +52,7 @@ export const registerToolPlugin = <R>(plugin: {
|
|||
Effect.gen(function* () {
|
||||
const registrations: Array<{
|
||||
readonly name: string
|
||||
readonly tool: Tool.AnyTool
|
||||
readonly tool: Tool.Any
|
||||
readonly options?: Tool.RegisterOptions
|
||||
}> = []
|
||||
callback({
|
||||
|
|
@ -73,8 +73,5 @@ export const registerToolPlugin = <R>(plugin: {
|
|||
yield* plugin.effect(context)
|
||||
})
|
||||
|
||||
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))
|
||||
registry.snapshot().pipe(Effect.flatMap((toolSet) => toolSet.execute(input)))
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import { Image } from "@opencode-ai/core/image"
|
|||
import { testEffect } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { location } from "./fixture/location"
|
||||
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
|
||||
import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
|
||||
|
||||
let assertion: Deferred.Deferred<PermissionV2.AssertInput> | undefined
|
||||
let decision: Effect.Effect<void, PermissionV2.Error> = Effect.void
|
||||
|
|
@ -241,10 +241,41 @@ const mcp = Layer.mock(MCP.Service, {
|
|||
description: "Lookup",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
}),
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make("direct"),
|
||||
name: "fail",
|
||||
codemode: false,
|
||||
description: "Always fails",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
}),
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make("direct"),
|
||||
name: "media",
|
||||
codemode: false,
|
||||
description: "Returns text and an image",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
}),
|
||||
]),
|
||||
callTool: (input) =>
|
||||
Effect.sync(() => {
|
||||
calls += 1
|
||||
if (input.name === "fail")
|
||||
return new MCP.ToolResult({
|
||||
server: MCP.ServerName.make(input.server),
|
||||
tool: input.name,
|
||||
isError: true,
|
||||
content: [{ type: "text", text: "search index unavailable" }],
|
||||
})
|
||||
if (input.name === "media")
|
||||
return new MCP.ToolResult({
|
||||
server: MCP.ServerName.make(input.server),
|
||||
tool: input.name,
|
||||
isError: false,
|
||||
content: [
|
||||
{ type: "text", text: "rendered chart" },
|
||||
{ type: "media", data: "aGVsbG8=", mimeType: "image/png" },
|
||||
],
|
||||
})
|
||||
return new MCP.ToolResult({
|
||||
server: MCP.ServerName.make(input.server),
|
||||
tool: input.name,
|
||||
|
|
@ -647,9 +678,7 @@ test("loads and reads MCP resources", async () => {
|
|||
})
|
||||
expect(server.clientVersion()).toMatchObject({ name: "sdk", version: "1.2.3" })
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
resourceMcpLayer(server.url, undefined, { clientInfo: { name: "sdk", version: "1.2.3" } }),
|
||||
),
|
||||
Effect.provide(resourceMcpLayer(server.url, undefined, { clientInfo: { name: "sdk", version: "1.2.3" } })),
|
||||
)
|
||||
}),
|
||||
),
|
||||
|
|
@ -774,8 +803,8 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
|
|||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* waitForTool(registry, "execute")
|
||||
const materialized = yield* registry.materialize()
|
||||
const execute = materialized.definitions.find((tool) => tool.name === "execute")
|
||||
const definitions = yield* toolDefinitions(registry)
|
||||
const execute = definitions.find((tool) => tool.name === "execute")
|
||||
|
||||
expect(execute?.description).not.toContain("tools.demo.search")
|
||||
}),
|
||||
|
|
@ -793,6 +822,50 @@ it.effect("advertises MCP tools directly when Code Mode is disabled for the serv
|
|||
}),
|
||||
)
|
||||
|
||||
// Baseline (PLAN.md step 1): MCP isError must become one failed tool call, not a
|
||||
// success whose text happens to describe an error.
|
||||
it.effect("fails the call when MCP reports isError", () =>
|
||||
Effect.gen(function* () {
|
||||
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
|
||||
decision = Effect.void
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* waitForTool(registry, "direct_fail")
|
||||
|
||||
const execution = yield* executeTool(registry, {
|
||||
sessionID: SessionV2.ID.make("ses_mcp_is_error"),
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call_mcp_is_error", name: "direct_fail", input: {} },
|
||||
})
|
||||
|
||||
expect(execution).toMatchObject({ status: "error", error: { message: "search index unavailable" } })
|
||||
expect(execution.content).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
// Baseline (PLAN.md step 1): mixed MCP text and media content must reach the model intact.
|
||||
it.effect("preserves MCP text and media content for the model", () =>
|
||||
Effect.gen(function* () {
|
||||
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
|
||||
decision = Effect.void
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* waitForTool(registry, "direct_media")
|
||||
|
||||
const execution = yield* executeTool(registry, {
|
||||
sessionID: SessionV2.ID.make("ses_mcp_media"),
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call_mcp_media", name: "direct_media", input: {} },
|
||||
})
|
||||
|
||||
expect(execution.status).toBe("completed")
|
||||
if (execution.status !== "completed") return
|
||||
expect(execution.output).toBe("rendered chart")
|
||||
expect(execution.content).toMatchObject([
|
||||
{ type: "text", text: "rendered chart" },
|
||||
{ type: "file", mime: "image/png" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("waits for permission before calling an MCP tool", () =>
|
||||
Effect.gen(function* () {
|
||||
calls = 0
|
||||
|
|
@ -802,7 +875,7 @@ it.effect("waits for permission before calling an MCP tool", () =>
|
|||
const registry = yield* ToolRegistry.Service
|
||||
yield* waitForTool(registry, "execute")
|
||||
|
||||
const fiber = yield* settleTool(registry, {
|
||||
const fiber = yield* executeTool(registry, {
|
||||
sessionID: SessionV2.ID.make("ses_mcp_permission"),
|
||||
...toolIdentity,
|
||||
call: {
|
||||
|
|
@ -841,7 +914,7 @@ it.effect("does not call MCP when permission is blocked", () =>
|
|||
const registry = yield* ToolRegistry.Service
|
||||
yield* waitForTool(registry, "execute")
|
||||
|
||||
const settlement = yield* settleTool(registry, {
|
||||
const execution = yield* executeTool(registry, {
|
||||
sessionID: SessionV2.ID.make("ses_mcp_blocked"),
|
||||
...toolIdentity,
|
||||
call: {
|
||||
|
|
@ -851,8 +924,9 @@ it.effect("does not call MCP when permission is blocked", () =>
|
|||
input: { code: "return await tools.demo.search({})" },
|
||||
},
|
||||
})
|
||||
expect(settlement.result).toEqual({ type: "text", value: "Unable to execute demo_search" })
|
||||
expect(settlement.output?.structured).toEqual({
|
||||
expect(execution.status).toBe("completed")
|
||||
expect(execution.content).toEqual([{ type: "text", text: "Unable to execute demo_search" }])
|
||||
expect(execution.metadata).toEqual({
|
||||
toolCalls: [{ tool: "demo.search", status: "error" }],
|
||||
error: true,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -258,7 +258,7 @@ describe("PluginV2", () => {
|
|||
description: "Plugin tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ ok: true }),
|
||||
execute: () => Effect.succeed({ output: { ok: true } }),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
|
|
@ -267,10 +267,10 @@ describe("PluginV2", () => {
|
|||
})
|
||||
|
||||
yield* plugins.activate([versioned(plugin)])
|
||||
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toContain("plugin_tool")
|
||||
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toContain("plugin_tool")
|
||||
|
||||
yield* plugins.activate([])
|
||||
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).not.toContain("plugin_tool")
|
||||
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).not.toContain("plugin_tool")
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -283,7 +283,7 @@ describe("PluginV2", () => {
|
|||
description,
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ ok: true }),
|
||||
execute: () => Effect.succeed({ output: { ok: true } }),
|
||||
})
|
||||
const plugin = EffectPlugin.define({
|
||||
id: "grouped-tools",
|
||||
|
|
@ -299,7 +299,7 @@ describe("PluginV2", () => {
|
|||
|
||||
yield* plugins.activate([versioned(plugin)])
|
||||
|
||||
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toEqual([
|
||||
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toEqual([
|
||||
"plain",
|
||||
"context7_look_up",
|
||||
"execute",
|
||||
|
|
@ -307,14 +307,14 @@ describe("PluginV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("fires before/after tool hooks with mutable events around settlement", () =>
|
||||
it.effect("fires before/after tool hooks with mutable events around execution", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const executed: unknown[] = []
|
||||
const seen: {
|
||||
before?: unknown
|
||||
after?: { input: unknown; result: unknown; output: unknown }
|
||||
after?: { input: unknown; status: string; content: unknown; metadata: unknown }
|
||||
} = {}
|
||||
|
||||
const plugin = EffectPlugin.define({
|
||||
|
|
@ -329,7 +329,8 @@ describe("PluginV2", () => {
|
|||
description: "Echo",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })),
|
||||
execute: ({ text }) =>
|
||||
Effect.sync(() => executed.push({ text })).pipe(Effect.as({ output: { text } })),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
|
|
@ -348,9 +349,23 @@ describe("PluginV2", () => {
|
|||
yield* ctx.tool
|
||||
.hook("execute.after", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.after = { input: event.input, result: event.result, output: event.output }
|
||||
event.result = { type: "text", value: "after-mutated" }
|
||||
event.output = { structured: { rewritten: true }, content: [] }
|
||||
seen.after = {
|
||||
input: event.input,
|
||||
status: event.status,
|
||||
content: event.content,
|
||||
metadata: event.metadata,
|
||||
}
|
||||
if (event.status !== "completed") return
|
||||
event.content = [{ type: "text", text: "after-mutated" }]
|
||||
event.metadata = { rewritten: true }
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.asVoid)
|
||||
|
||||
yield* ctx.tool
|
||||
.hook("execute.after", (event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.status === "completed") event.content = [] as never
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.asVoid)
|
||||
|
|
@ -359,8 +374,8 @@ describe("PluginV2", () => {
|
|||
|
||||
yield* plugins.activate([versioned(plugin)])
|
||||
|
||||
const materialized = yield* registry.materialize()
|
||||
const settlement = yield* materialized.settle({
|
||||
const toolSet = yield* registry.snapshot()
|
||||
const execution = yield* toolSet.execute({
|
||||
sessionID: SessionV2.ID.make("ses_hooks"),
|
||||
agent: AgentV2.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_hooks"),
|
||||
|
|
@ -371,11 +386,15 @@ describe("PluginV2", () => {
|
|||
expect(executed).toEqual([{ text: "before-mutated" }])
|
||||
expect(seen.after).toEqual({
|
||||
input: { text: "before-mutated" },
|
||||
result: { type: "json", value: { text: "before-mutated" } },
|
||||
output: { structured: { text: "before-mutated" }, content: [] },
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: '{"text":"before-mutated"}' }],
|
||||
metadata: undefined,
|
||||
})
|
||||
expect(execution).toMatchObject({
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: "after-mutated" }],
|
||||
metadata: { rewritten: true },
|
||||
})
|
||||
expect(settlement.result).toEqual({ type: "text", value: "after-mutated" })
|
||||
expect(settlement.output).toEqual({ structured: { rewritten: true }, content: [] })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
|
|||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
import { Tool } from "@opencode-ai/plugin/v2/tool"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
|
|
@ -270,7 +271,7 @@ describe("fromPromise", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("constructs plain Promise tool declarations in the host", () =>
|
||||
it.effect("constructs plain Promise tool definitions in the host", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
|
@ -280,35 +281,41 @@ describe("fromPromise", () => {
|
|||
id: "promise-tool",
|
||||
setup: async (ctx) => {
|
||||
await ctx.tool.transform((tools) => {
|
||||
tools.add({
|
||||
name: "hello",
|
||||
options: { codemode: false },
|
||||
description: "Hello",
|
||||
input: Schema.Struct({ name: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: async ({ name }, context) => {
|
||||
await context.progress({ structured: { phase: "greeting" } })
|
||||
return `Hello, ${name}!`
|
||||
},
|
||||
})
|
||||
tools.add(
|
||||
"hello",
|
||||
Tool.make({
|
||||
description: "Hello",
|
||||
input: Schema.Struct({ name: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: async ({ name }, context) => {
|
||||
await context.progress({ phase: "greeting" })
|
||||
return { output: `Hello, ${name}!` }
|
||||
},
|
||||
}),
|
||||
{ codemode: false },
|
||||
)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
|
||||
|
||||
const materialized = yield* registry.materialize()
|
||||
expect(materialized.definitions).toContainEqual(expect.objectContaining({ name: "hello", description: "Hello" }))
|
||||
const toolSet = yield* registry.snapshot()
|
||||
expect(toolSet.definitions).toContainEqual(expect.objectContaining({ name: "hello", description: "Hello" }))
|
||||
expect(
|
||||
yield* materialized.settle({
|
||||
yield* toolSet.execute({
|
||||
sessionID: SessionV2.ID.make("ses_promise_tool"),
|
||||
agent: AgentV2.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_promise_tool"),
|
||||
progress: (update) => Effect.sync(() => progress.push(update)),
|
||||
call: { type: "tool-call", id: "call_promise_tool", name: "hello", input: { name: "world" } },
|
||||
}),
|
||||
).toMatchObject({ result: { type: "text", value: "Hello, world!" } })
|
||||
expect(progress).toEqual([{ structured: { phase: "greeting" }, content: [] }])
|
||||
).toMatchObject({
|
||||
status: "completed",
|
||||
output: "Hello, world!",
|
||||
content: [{ type: "text", text: "Hello, world!" }],
|
||||
})
|
||||
expect(progress).toEqual([{ phase: "greeting" }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -95,10 +95,10 @@ const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effec
|
|||
const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
|
||||
const tools = Layer.mock(ToolRegistry.Service, {
|
||||
materialize: () =>
|
||||
snapshot: () =>
|
||||
Effect.succeed({
|
||||
definitions: [ToolDefinition.make({ name: "lookup", description: "Lookup", inputSchema: { type: "object" } })],
|
||||
settle: () => Effect.die(new Error("unused")),
|
||||
execute: () => Effect.die(new Error("unused")),
|
||||
}),
|
||||
register: () => Effect.die(new Error("unused")),
|
||||
registerBatch: () => Effect.die(new Error("unused")),
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
|||
import { tempLocationLayer } from "./fixture/location"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { registerToolPlugin, settleTool } from "./lib/tool"
|
||||
import { executeTool, registerToolPlugin } from "./lib/tool"
|
||||
|
||||
const readToolNode = makeLocationNode({
|
||||
name: "test/read-tool-plugin",
|
||||
|
|
@ -163,7 +163,7 @@ describe("SessionInstructions", () => {
|
|||
|
||||
// A read deep under sub/ discovers deep and sub AGENTS.md, walking up to but
|
||||
// excluding the Location root (already supplied by core initial instructions).
|
||||
yield* settleTool(registry, readCall(sessionID, "call-deep", "sub/deep/file.txt"))
|
||||
yield* executeTool(registry, readCall(sessionID, "call-deep", "sub/deep/file.txt"))
|
||||
|
||||
const firstInjected = yield* synthetics(sessionID)
|
||||
expect(firstInjected).toHaveLength(1)
|
||||
|
|
@ -179,7 +179,7 @@ describe("SessionInstructions", () => {
|
|||
|
||||
// A sibling read under sub/other discovers only the new AGENTS.md; sub is already
|
||||
// injected for this session so it is not re-emitted, and the root is still excluded.
|
||||
yield* settleTool(registry, readCall(sessionID, "call-other", "sub/other/file2.txt"))
|
||||
yield* executeTool(registry, readCall(sessionID, "call-other", "sub/other/file2.txt"))
|
||||
|
||||
const secondInjected = yield* synthetics(sessionID)
|
||||
expect(secondInjected).toHaveLength(2)
|
||||
|
|
@ -210,7 +210,7 @@ describe("SessionInstructions", () => {
|
|||
yield* seedSynthetic(sessionID, [subPath])
|
||||
expect(yield* synthetics(sessionID)).toHaveLength(1)
|
||||
|
||||
yield* settleTool(registry, readCall(sessionID, "call-sub", "sub/file.txt"))
|
||||
yield* executeTool(registry, readCall(sessionID, "call-sub", "sub/file.txt"))
|
||||
|
||||
// The durable claim on the prior synthetic prevents re-injection; no new synthetic.
|
||||
expect(yield* synthetics(sessionID)).toHaveLength(1)
|
||||
|
|
@ -236,7 +236,7 @@ describe("SessionInstructions", () => {
|
|||
|
||||
// Listing packages/foo/ discovers its own AGENTS.md, walking up to but excluding
|
||||
// the Location root (already supplied by core initial instructions).
|
||||
yield* settleTool(registry, readCall(sessionID, "call-list", "packages/foo"))
|
||||
yield* executeTool(registry, readCall(sessionID, "call-list", "packages/foo"))
|
||||
|
||||
const firstInjected = yield* synthetics(sessionID)
|
||||
expect(firstInjected).toHaveLength(1)
|
||||
|
|
@ -247,7 +247,7 @@ describe("SessionInstructions", () => {
|
|||
|
||||
// A subsequent file read under the listed directory is a dedup: pkg's AGENTS.md is
|
||||
// already injected for this session, so nothing new is emitted.
|
||||
yield* settleTool(registry, readCall(sessionID, "call-file", "packages/foo/file.txt"))
|
||||
yield* executeTool(registry, readCall(sessionID, "call-file", "packages/foo/file.txt"))
|
||||
|
||||
expect(yield* synthetics(sessionID)).toHaveLength(1)
|
||||
}),
|
||||
|
|
@ -269,7 +269,7 @@ describe("SessionInstructions", () => {
|
|||
|
||||
// The walk starts and stops at the Location root: the root AGENTS.md is searched but
|
||||
// dropped by the dirname filter, and up() only walks upward so nested dirs are unseen.
|
||||
yield* settleTool(registry, readCall(sessionID, "call-root-list", "."))
|
||||
yield* executeTool(registry, readCall(sessionID, "call-root-list", "."))
|
||||
|
||||
expect(yield* synthetics(sessionID)).toHaveLength(0)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -367,8 +367,7 @@ Recent work
|
|||
state: SessionMessage.ToolStateRunning.make({
|
||||
status: "running",
|
||||
input: { path: "README.md" },
|
||||
content: [],
|
||||
structured: { type: "media", mime: "image/png" },
|
||||
metadata: { type: "media", mime: "image/png" },
|
||||
}),
|
||||
time: { created },
|
||||
}),
|
||||
|
|
@ -388,7 +387,6 @@ Recent work
|
|||
name: "hello.png",
|
||||
},
|
||||
],
|
||||
structured: {},
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
|
|
@ -403,7 +401,6 @@ Recent work
|
|||
status: "completed",
|
||||
input: { query: "Effect" },
|
||||
content: [{ type: "text", text: "Found it" }],
|
||||
structured: {},
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
|
|
@ -416,8 +413,6 @@ Recent work
|
|||
state: SessionMessage.ToolStateError.make({
|
||||
status: "error",
|
||||
input: { path: "README.md" },
|
||||
content: [],
|
||||
structured: {},
|
||||
error: { type: "unknown", message: "Denied" },
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
|
|
@ -473,7 +468,7 @@ Recent work
|
|||
providerMetadata: { provider: { continuation: "failed" } },
|
||||
result: {
|
||||
type: "error",
|
||||
value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} },
|
||||
value: { error: { type: "unknown", message: "Denied" }, content: [] },
|
||||
},
|
||||
},
|
||||
])
|
||||
|
|
@ -575,9 +570,7 @@ Recent work
|
|||
state: SessionMessage.ToolStateCompleted.make({
|
||||
status: "completed",
|
||||
input: { query: "Effect" },
|
||||
content: [],
|
||||
structured: {},
|
||||
result: { type: "json", value: { found: true } },
|
||||
content: [{ type: "text", text: '{"found":true}' }],
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
|
|
@ -592,8 +585,6 @@ Recent work
|
|||
status: "error",
|
||||
input: { query: "Effect" },
|
||||
error: { type: "unknown", message: "Step interrupted" },
|
||||
content: [],
|
||||
structured: {},
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
|
|
@ -620,8 +611,10 @@ Recent work
|
|||
type: "tool-result",
|
||||
id: "hosted-completed",
|
||||
name: "web_search",
|
||||
result: { type: "json", value: { found: true } },
|
||||
result: { type: "text", value: '{"found":true}' },
|
||||
providerExecuted: true,
|
||||
cache: undefined,
|
||||
metadata: undefined,
|
||||
providerMetadata: { provider: { itemId: "result_completed" } },
|
||||
},
|
||||
{
|
||||
|
|
@ -630,7 +623,7 @@ Recent work
|
|||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: undefined,
|
||||
providerMetadata: { provider: { itemId: "call_failed" } },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
|
|
@ -641,18 +634,17 @@ Recent work
|
|||
value: {
|
||||
error: { type: "unknown", message: "Step interrupted" },
|
||||
content: [],
|
||||
structured: {},
|
||||
},
|
||||
},
|
||||
providerExecuted: true,
|
||||
cache: undefined,
|
||||
metadata: undefined,
|
||||
providerMetadata: undefined,
|
||||
providerMetadata: { provider: { itemId: "result_failed" } },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("drops provider-native continuation metadata after a model switch", () => {
|
||||
test("drops model-scoped continuation metadata after a model switch but keeps hosted result payloads", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.Assistant.make({
|
||||
|
|
@ -676,9 +668,7 @@ Recent work
|
|||
state: SessionMessage.ToolStateCompleted.make({
|
||||
status: "completed",
|
||||
input: { query: "Effect" },
|
||||
content: [],
|
||||
structured: {},
|
||||
result: { type: "json", value: { status: "completed" } },
|
||||
content: [{ type: "text", text: '{"status":"completed"}' }],
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
|
|
@ -692,8 +682,7 @@ Recent work
|
|||
state: SessionMessage.ToolStateCompleted.make({
|
||||
status: "completed",
|
||||
input: { path: "README.md" },
|
||||
content: [],
|
||||
structured: { text: "Hello" },
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
|
|
@ -718,11 +707,13 @@ Recent work
|
|||
type: "tool-result",
|
||||
id: "hosted-old-model",
|
||||
name: "web_search",
|
||||
result: { type: "json", value: { status: "completed" } },
|
||||
result: { type: "text", value: '{"status":"completed"}' },
|
||||
providerExecuted: true,
|
||||
cache: undefined,
|
||||
metadata: undefined,
|
||||
providerMetadata: undefined,
|
||||
// Hosted result payloads are provider-format state and must survive a
|
||||
// model switch within the same provider for replay to stay valid.
|
||||
providerMetadata: { provider: { itemId: "hosted-old-model" } },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
|
|
@ -738,7 +729,7 @@ Recent work
|
|||
type: "tool-result",
|
||||
id: "local-old-model",
|
||||
name: "read",
|
||||
result: { type: "json", value: { text: "Hello" } },
|
||||
result: { type: "text", value: "Hello" },
|
||||
providerExecuted: false,
|
||||
cache: undefined,
|
||||
metadata: undefined,
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ const capture = (providerMetadataKey = "anthropic", options?: { readonly interru
|
|||
}
|
||||
|
||||
const call = LLMEvent.toolCall({ id: "call-image", name: "read", input: { path: "pixel.png" } })
|
||||
const result = LLMEvent.toolResult({
|
||||
const hostedResult = LLMEvent.toolResult({
|
||||
id: "call-image",
|
||||
name: "read",
|
||||
result: {
|
||||
|
|
@ -60,25 +60,28 @@ const result = LLMEvent.toolResult({
|
|||
{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png", name: "pixel.png" },
|
||||
],
|
||||
},
|
||||
output: {
|
||||
structured: { type: "media", mime: "image/png" },
|
||||
content: [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png", name: "pixel.png" },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
test("local tool success serializes media base64 once and reconstructs from structured content", async () => {
|
||||
test("local tool success serializes media base64 once through canonical content", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
await Effect.runPromise(publisher.publish(result))
|
||||
await Effect.runPromise(
|
||||
publisher.toolExecution(call.id, call.name, {
|
||||
status: "completed",
|
||||
output: { type: "media", mime: "image/png" },
|
||||
content: [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png", name: "pixel.png" },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const success = published.find((event) => event.type === "session.tool.success.1")
|
||||
const success = published.find((event) => event.type === "session.tool.success.2")
|
||||
expect(success).toBeDefined()
|
||||
const serialized = JSON.stringify(success)
|
||||
expect(serialized.split(base64)).toHaveLength(2)
|
||||
expect(success?.data).not.toHaveProperty("result")
|
||||
expect(success?.data).not.toHaveProperty("output")
|
||||
|
||||
expect(success?.data).toMatchObject({
|
||||
content: [
|
||||
|
|
@ -88,29 +91,41 @@ test("local tool success serializes media base64 once and reconstructs from stru
|
|||
})
|
||||
})
|
||||
|
||||
test("provider-executed success retains its raw provider result", async () => {
|
||||
test("provider-executed success derives content and retains provider result state", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true })))
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true })))
|
||||
const success = published.find((event) => event.type === "session.tool.success.1")
|
||||
expect(success?.data).toHaveProperty("result")
|
||||
await Effect.runPromise(
|
||||
publisher.publish(
|
||||
LLMEvent.toolResult({
|
||||
...hostedResult,
|
||||
providerExecuted: true,
|
||||
providerMetadata: { anthropic: { result: { type: "content", value: [] } } },
|
||||
}),
|
||||
),
|
||||
)
|
||||
const success = published.find((event) => event.type === "session.tool.success.2")
|
||||
expect(success?.data).not.toHaveProperty("result")
|
||||
expect(success?.data).toMatchObject({
|
||||
executed: true,
|
||||
content: [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" },
|
||||
],
|
||||
resultState: { result: { type: "content" } },
|
||||
})
|
||||
})
|
||||
|
||||
test("interrupted progress publication remains in the terminal failure snapshot", async () => {
|
||||
test("interrupted progress metadata remains in the terminal failure snapshot", async () => {
|
||||
const { published, publisher } = capture("anthropic", { interruptProgress: true })
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
const exit = await Effect.runPromiseExit(
|
||||
publisher.progress(call.id, {
|
||||
structured: { phase: "visible" },
|
||||
content: [{ type: "text", text: "visible" }],
|
||||
}),
|
||||
publisher.progress(call.id, { phase: "visible" }),
|
||||
)
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||
await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" }))
|
||||
|
||||
expect(published.find((event) => event.type === "session.tool.failed.1")?.data).toMatchObject({
|
||||
expect(published.find((event) => event.type === "session.tool.failed.2")?.data).toMatchObject({
|
||||
metadata: { phase: "visible" },
|
||||
content: [{ type: "text", text: "visible" }],
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -119,7 +134,7 @@ test("failure before progress omits partial output fields", async () => {
|
|||
await Effect.runPromise(publisher.publish(call))
|
||||
await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" }))
|
||||
|
||||
const failed = published.find((event) => event.type === "session.tool.failed.1")?.data
|
||||
const failed = published.find((event) => event.type === "session.tool.failed.2")?.data
|
||||
expect(failed).not.toHaveProperty("content")
|
||||
expect(failed).not.toHaveProperty("metadata")
|
||||
})
|
||||
|
|
@ -192,7 +207,7 @@ test("provider-executed tool metadata is flattened using the route key", async (
|
|||
expect(published.find((event) => event.type === "session.tool.called.1")?.data).toMatchObject({
|
||||
state: { itemId: "call" },
|
||||
})
|
||||
expect(published.find((event) => event.type === "session.tool.success.1")?.data).toMatchObject({
|
||||
expect(published.find((event) => event.type === "session.tool.success.2")?.data).toMatchObject({
|
||||
resultState: { itemId: "result" },
|
||||
})
|
||||
})
|
||||
|
|
@ -201,29 +216,30 @@ test("binary failure emits no success event", async () => {
|
|||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
await Effect.runPromise(
|
||||
publisher.publish(
|
||||
LLMEvent.toolResult({
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
result: { type: "error", value: "Cannot read binary file" },
|
||||
}),
|
||||
),
|
||||
publisher.toolExecution(call.id, call.name, {
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: "Cannot read binary file" },
|
||||
}),
|
||||
)
|
||||
expect(published.some((event) => event.type === "session.tool.success.1")).toBe(false)
|
||||
expect(published.some((event) => event.type === "session.tool.failed.1")).toBe(true)
|
||||
expect(published.some((event) => event.type === "session.tool.success.2")).toBe(false)
|
||||
expect(published.some((event) => event.type === "session.tool.failed.2")).toBe(true)
|
||||
})
|
||||
|
||||
test("success event data can carry a provider-executed result", () => {
|
||||
test("success event data can carry provider-executed result state", () => {
|
||||
const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
|
||||
sessionID,
|
||||
assistantMessageID: SessionMessage.ID.create(),
|
||||
callID: "call-old",
|
||||
structured: { type: "media", mime: "image/png" },
|
||||
content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }],
|
||||
result: { type: "content", value: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }] },
|
||||
executed: true,
|
||||
resultState: {
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }],
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(decoded.result).toMatchObject({ type: "content" })
|
||||
expect(decoded.resultState).toMatchObject({ result: { type: "content" } })
|
||||
})
|
||||
|
||||
test("step finish records settlement without publishing step ended", async () => {
|
||||
|
|
|
|||
|
|
@ -8,23 +8,24 @@ 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 { executeTool, toolDefinitions } from "./lib/tool"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const bounds: ToolOutputStore.BoundInput[] = []
|
||||
const retentionFailure = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") })
|
||||
const outputStore = Layer.mock(ToolOutputStore.Service, {
|
||||
limits: () => Effect.succeed({ maxLines: ToolOutputStore.MAX_LINES, maxBytes: ToolOutputStore.MAX_BYTES }),
|
||||
bound: (input) => {
|
||||
if (input.callID === "call-retention-failure") return Effect.fail(retentionFailure)
|
||||
return Effect.sync(() => bounds.push(input)).pipe(
|
||||
Effect.as(
|
||||
input.callID === "call-bounded"
|
||||
? {
|
||||
output: { structured: {}, content: [{ type: "text" as const, text: "bounded reference" }] },
|
||||
content: [{ type: "text" as const, text: "bounded reference" }],
|
||||
outputPaths: ["/managed/generic"],
|
||||
}
|
||||
: { output: input.output, outputPaths: [] },
|
||||
: { content: input.content, outputPaths: [] },
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
@ -63,24 +64,20 @@ const call = (name: string, id = `call-${name}`): ToolRegistry.ExecuteInput => (
|
|||
call: { type: "tool-call", id, name, input: { text: name } },
|
||||
})
|
||||
|
||||
const make = (permission?: string) => {
|
||||
const tool = Tool.make({
|
||||
const make = () =>
|
||||
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 }],
|
||||
execute: ({ text }) => Effect.succeed({ output: { text }, content: text }),
|
||||
})
|
||||
return permission ? Tool.withPermission(tool, permission) : tool
|
||||
}
|
||||
|
||||
const constant = (text: string) =>
|
||||
Tool.make({
|
||||
description: "Return text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: () => Effect.succeed({ text }),
|
||||
toModelOutput: ({ output }) => [{ type: "text" as const, text: output.text }],
|
||||
execute: () => Effect.succeed({ output: { text }, content: text }),
|
||||
})
|
||||
|
||||
describe("ToolRegistry", () => {
|
||||
|
|
@ -91,7 +88,21 @@ describe("ToolRegistry", () => {
|
|||
|
||||
expect(error).toBeInstanceOf(Tool.RegistrationError)
|
||||
expect(error.message).toBe('Invalid tool namespace: "slack..admin"')
|
||||
expect((yield* service.materialize()).definitions).toEqual([])
|
||||
expect((yield* service.snapshot()).definitions).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects invalid and colliding normalized names", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const invalid = yield* service.register({ "123": make() }, { codemode: false }).pipe(Effect.flip)
|
||||
expect(invalid.message).toBe("Invalid tool name: 123")
|
||||
|
||||
const collision = yield* service
|
||||
.register({ "echo.tool": make(), echo_tool: make() }, { codemode: false })
|
||||
.pipe(Effect.flip)
|
||||
expect(collision.message).toBe("Duplicate normalized tool name: echo_tool")
|
||||
expect((yield* service.snapshot()).definitions).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -106,19 +117,15 @@ describe("ToolRegistry", () => {
|
|||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(Tool.RegistrationError)
|
||||
expect((yield* service.materialize()).definitions).toEqual([])
|
||||
expect((yield* service.snapshot()).definitions).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({
|
||||
question: make(),
|
||||
bash: make(),
|
||||
edit: make("edit"),
|
||||
write: make("edit"),
|
||||
}, { codemode: false })
|
||||
yield* service.register({ question: make(), bash: make() }, { codemode: false })
|
||||
yield* service.register({ edit: make(), write: make() }, { codemode: false, permission: "edit" })
|
||||
const names = (permissions: PermissionV2.Ruleset) =>
|
||||
toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
|
||||
|
||||
|
|
@ -139,18 +146,15 @@ describe("ToolRegistry", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps permission decoration isolated between registrations", () =>
|
||||
it.effect("keeps permission options isolated between registrations", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const shared = make()
|
||||
yield* service.register({ first: shared }, { codemode: false })
|
||||
yield* service.register({ second: Tool.withPermission(shared, "edit") }, { codemode: false })
|
||||
Tool.withPermission(shared, "question")
|
||||
yield* service.register({ second: shared }, { codemode: false, permission: "edit" })
|
||||
|
||||
expect(
|
||||
(yield* toolDefinitions(service, [{ action: "edit", resource: "*", effect: "deny" }])).map(
|
||||
(definition) => definition.name,
|
||||
),
|
||||
(yield* toolDefinitions(service, [{ action: "edit", resource: "*", effect: "deny" }])).map((tool) => tool.name),
|
||||
).toEqual(["first"])
|
||||
}),
|
||||
)
|
||||
|
|
@ -191,41 +195,47 @@ describe("ToolRegistry", () => {
|
|||
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" })),
|
||||
}),
|
||||
}, { codemode: false })
|
||||
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" })),
|
||||
}),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
...identity,
|
||||
call: { type: "tool-call", id: "failed", name: "failed", input: {} },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Denied" })
|
||||
).toEqual({ status: "error", error: { type: "tool.execution", message: "Denied" } })
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
...identity,
|
||||
call: { type: "tool-call", id: "missing", name: "missing", input: {} },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Unknown tool: missing" })
|
||||
).toEqual({ status: "error", error: { type: "tool.unknown", message: "Unknown tool: missing" } })
|
||||
|
||||
yield* service.register({
|
||||
defect: Tool.make({
|
||||
description: "Defect",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.die("unexpected executor defect"),
|
||||
}),
|
||||
}, { codemode: false })
|
||||
yield* service.register(
|
||||
{
|
||||
defect: Tool.make({
|
||||
description: "Defect",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.die("unexpected executor defect"),
|
||||
}),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
expect(
|
||||
yield* service.materialize().pipe(
|
||||
Effect.flatMap((materialized) =>
|
||||
materialized.settle({
|
||||
yield* service.snapshot().pipe(
|
||||
Effect.flatMap((toolSet) =>
|
||||
toolSet.execute({
|
||||
sessionID,
|
||||
...identity,
|
||||
call: { type: "tool-call", id: "defect", name: "defect", input: {} },
|
||||
|
|
@ -237,12 +247,12 @@ describe("ToolRegistry", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("propagates retention failures through settlement", () =>
|
||||
it.effect("propagates retention failures through execution", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ echo: make() }, { codemode: false })
|
||||
const materialized = yield* service.materialize()
|
||||
const exit = yield* materialized.settle(call("echo", "call-retention-failure")).pipe(Effect.exit)
|
||||
const toolSet = yield* service.snapshot()
|
||||
const exit = yield* toolSet.execute(call("echo", "call-retention-failure")).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBe(retentionFailure)
|
||||
|
|
@ -250,79 +260,88 @@ describe("ToolRegistry", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("exposes settlement only through materialization", () =>
|
||||
it.effect("exposes execution only through a snapshot", () =>
|
||||
Effect.gen(function* () {
|
||||
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")
|
||||
expect(typeof service.snapshot).toBe("function")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes complete invocation identity to the canonical handler", () =>
|
||||
it.effect("passes complete call identity to tool execution", () =>
|
||||
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 })),
|
||||
}),
|
||||
}, { codemode: false })
|
||||
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({ output: { ok: true } })),
|
||||
}),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
...identity,
|
||||
call: { type: "tool-call", id: "call-context", name: "context", input: {} },
|
||||
})
|
||||
expect(contexts).toEqual([
|
||||
{ sessionID, ...identity, callID: "call-context", progress: expect.any(Function) },
|
||||
])
|
||||
expect(contexts).toEqual([{ sessionID, ...identity, callID: "call-context", progress: expect.any(Function) }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("encodes output and applies generic settlement bounding", () =>
|
||||
it.effect("encodes output and applies generic execution bounding", () =>
|
||||
Effect.gen(function* () {
|
||||
bounds.length = 0
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ bounded: make() }, { codemode: false })
|
||||
expect(
|
||||
yield* settleTool(service, {
|
||||
yield* executeTool(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" }] },
|
||||
status: "completed",
|
||||
output: { text: "complete" },
|
||||
content: [{ type: "text", text: "bounded reference" }],
|
||||
outputPaths: ["/managed/generic"],
|
||||
})
|
||||
expect(bounds).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes image tool output at settlement and drops unresizable images", () =>
|
||||
it.effect("normalizes image tool output at execution and drops unresizable images", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({
|
||||
snapshot: Tool.make({
|
||||
description: "Return images",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.succeed({ text }),
|
||||
toModelOutput: ({ output }) => [
|
||||
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
|
||||
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
|
||||
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "corrupt.png" },
|
||||
{ type: "text", text: output.text },
|
||||
],
|
||||
}),
|
||||
}, { codemode: false })
|
||||
yield* service.register(
|
||||
{
|
||||
snapshot: Tool.make({
|
||||
description: "Return images",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) =>
|
||||
Effect.succeed({
|
||||
output: { text },
|
||||
content: [
|
||||
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
|
||||
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
|
||||
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "corrupt.png" },
|
||||
{ type: "text", text },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
|
||||
const settlement = yield* settleTool(service, call("snapshot"))
|
||||
expect(settlement.output?.content).toEqual([
|
||||
const execution = yield* executeTool(service, call("snapshot"))
|
||||
expect(execution.content).toEqual([
|
||||
{ type: "file", uri: "data:image/jpeg;base64,bm9ybWFsaXplZA==", mime: "image/jpeg", name: "frame.png" },
|
||||
{ type: "text", text: "snapshot" },
|
||||
{ type: "text", text: "[1 image omitted: could not be decoded.]" },
|
||||
|
|
@ -331,44 +350,31 @@ describe("ToolRegistry", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes image progress content before it is published", () =>
|
||||
it.effect("publishes progress metadata unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({
|
||||
progressive: Tool.make({
|
||||
description: "Emit image progress",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }, context) =>
|
||||
context
|
||||
.progress({
|
||||
structured: { stage: "capture" },
|
||||
content: [
|
||||
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
|
||||
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
|
||||
],
|
||||
})
|
||||
.pipe(Effect.as({ text })),
|
||||
}),
|
||||
}, { codemode: false })
|
||||
yield* service.register(
|
||||
{
|
||||
progressive: Tool.make({
|
||||
description: "Emit image progress",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }, context) =>
|
||||
context.progress({ stage: "capture" }).pipe(Effect.as({ output: { text } })),
|
||||
}),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
|
||||
const updates: ToolRegistry.Progress[] = []
|
||||
yield* settleTool(service, {
|
||||
yield* executeTool(service, {
|
||||
...call("progressive"),
|
||||
progress: (update) =>
|
||||
Effect.sync(() => {
|
||||
updates.push(update)
|
||||
}),
|
||||
})
|
||||
expect(updates).toEqual([
|
||||
{
|
||||
structured: { stage: "capture" },
|
||||
content: [
|
||||
{ type: "file", uri: "data:image/jpeg;base64,bm9ybWFsaXplZA==", mime: "image/jpeg", name: "frame.png" },
|
||||
{ type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(updates).toEqual([{ stage: "capture" }])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -382,23 +388,31 @@ describe("ToolRegistry", () => {
|
|||
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) }],
|
||||
}),
|
||||
}, { codemode: false })
|
||||
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({ output: { value }, content: String(value) })),
|
||||
}),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
|
||||
// Canonical content observes the decoded domain value; Code Mode observes the encoded value.
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
...identity,
|
||||
call: { type: "tool-call", id: "transformed", name: "transformed", input: { value: true } },
|
||||
}),
|
||||
).toEqual({ type: "text", value: "true" })
|
||||
).toEqual({
|
||||
status: "completed",
|
||||
output: { value: true },
|
||||
content: [{ type: "text", text: "yes" }],
|
||||
})
|
||||
expect(executed).toEqual(["yes"])
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
|
|
@ -406,35 +420,44 @@ describe("ToolRegistry", () => {
|
|||
...identity,
|
||||
call: { type: "tool-call", id: "invalid-input", name: "transformed", input: { value: "yes" } },
|
||||
}),
|
||||
).toMatchObject({ type: "error", value: expect.stringContaining("Invalid tool input") })
|
||||
).toMatchObject({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: 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" })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
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({ output: { value: "invalid" } }),
|
||||
}),
|
||||
execute: () => Effect.succeed({ value: "invalid" }),
|
||||
}),
|
||||
}, { codemode: false })
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
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") })
|
||||
).toMatchObject({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: expect.stringContaining("invalid value for its output schema") },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -443,12 +466,12 @@ describe("ToolRegistry", () => {
|
|||
const service = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
yield* service.register({ echo: constant("advertised") }, { codemode: false }).pipe(Scope.provide(scope))
|
||||
const request = yield* service.materialize()
|
||||
const request = yield* service.snapshot()
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* service.register({ echo: constant("replacement") }, { codemode: false })
|
||||
|
||||
expect((yield* request.settle(call("echo"))).result).toEqual({ type: "text", value: "advertised" })
|
||||
expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "replacement" })
|
||||
expect((yield* request.execute(call("echo"))).content).toEqual([{ type: "text", text: "advertised" }])
|
||||
expect((yield* executeTool(service, call("echo"))).content).toEqual([{ type: "text", text: "replacement" }])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -459,9 +482,9 @@ describe("ToolRegistry", () => {
|
|||
const overlay = yield* Scope.make()
|
||||
yield* service.register({ echo: constant("overlay") }, { codemode: false }).pipe(Scope.provide(overlay))
|
||||
|
||||
expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "overlay" })
|
||||
expect((yield* executeTool(service, call("echo"))).content).toEqual([{ type: "text", text: "overlay" }])
|
||||
yield* Scope.close(overlay, Exit.void)
|
||||
expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "base" })
|
||||
expect((yield* executeTool(service, call("echo"))).content).toEqual([{ type: "text", text: "base" }])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -476,12 +499,13 @@ describe("ToolRegistry", () => {
|
|||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push(`old:${text}`)).pipe(Effect.as({ text })),
|
||||
execute: ({ text }) =>
|
||||
Effect.sync(() => executed.push(`old:${text}`)).pipe(Effect.as({ output: { text } })),
|
||||
}),
|
||||
})
|
||||
.pipe(Scope.provide(scope))
|
||||
const materialized = yield* service.materialize()
|
||||
const execute = materialized.definitions.find((tool) => tool.name === "execute")
|
||||
const toolSet = yield* service.snapshot()
|
||||
const execute = toolSet.definitions.find((tool) => tool.name === "execute")
|
||||
expect(execute?.description).toContain("confined Code Mode runtime")
|
||||
expect(execute?.description).not.toContain("Echo text")
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
|
|
@ -490,11 +514,11 @@ describe("ToolRegistry", () => {
|
|||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ text })),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ output: { text } })),
|
||||
}),
|
||||
})
|
||||
|
||||
const settlement = yield* materialized.settle({
|
||||
const execution = yield* toolSet.execute({
|
||||
...call("execute"),
|
||||
call: {
|
||||
type: "tool-call",
|
||||
|
|
@ -504,7 +528,7 @@ describe("ToolRegistry", () => {
|
|||
},
|
||||
})
|
||||
|
||||
expect(settlement.result).toMatchObject({ type: "text" })
|
||||
expect(execution).toMatchObject({ status: "completed", content: [{ type: "text" }] })
|
||||
expect(executed).toEqual(["old:request"])
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import { AgentV2 } from "@opencode-ai/core/agent"
|
|||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigCompaction } from "@opencode-ai/core/config/compaction"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
|
||||
import {
|
||||
InstructionStateTable,
|
||||
SessionPendingTable,
|
||||
|
|
@ -238,43 +239,45 @@ const permission = Layer.succeed(
|
|||
)
|
||||
const echo = Layer.effectDiscard(
|
||||
ToolRegistry.Service.use((registry) =>
|
||||
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: () =>
|
||||
(toolExecutionGate ? Deferred.await(toolExecutionGate) : Effect.void).pipe(
|
||||
Effect.andThen(Effect.die("unexpected tool defect")),
|
||||
),
|
||||
}),
|
||||
// BigInt output with no model content forces ToolOutputStore.bound onto its
|
||||
// JSON.stringify encode path, which fails with a typed StorageError.
|
||||
storefail: Tool.make({
|
||||
description: "Produce output that cannot be persisted",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Any,
|
||||
execute: () => Effect.succeed({ big: 1n }),
|
||||
}),
|
||||
}, { codemode: false }),
|
||||
registry.register(
|
||||
{
|
||||
echo: Tool.make({
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
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 { output: { text }, content: text }
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
|
||||
}),
|
||||
defect: Tool.make({
|
||||
description: "Fail unexpectedly",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: () =>
|
||||
(toolExecutionGate ? Deferred.await(toolExecutionGate) : Effect.void).pipe(
|
||||
Effect.andThen(Effect.die("unexpected tool defect")),
|
||||
),
|
||||
}),
|
||||
// The wrapped ToolOutputStore below fails bound for this call ID with a
|
||||
// typed StorageError, exercising the infrastructure failure channel.
|
||||
storefail: Tool.make({
|
||||
description: "Produce output that cannot be persisted",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.succeed({ output: {} }),
|
||||
}),
|
||||
},
|
||||
{ codemode: false },
|
||||
),
|
||||
),
|
||||
)
|
||||
const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] })
|
||||
|
|
@ -379,6 +382,15 @@ const promptCatalog = Layer.mock(Catalog.Service, {
|
|||
small: () => Effect.succeed(undefined),
|
||||
},
|
||||
})
|
||||
// Pass-through bounding that fails "call-storefail" with a typed StorageError so
|
||||
// runner tests can exercise the infrastructure failure channel deterministically.
|
||||
const toolOutputStore = Layer.mock(ToolOutputStore.Service, {
|
||||
limits: () => Effect.succeed({ maxLines: ToolOutputStore.MAX_LINES, maxBytes: ToolOutputStore.MAX_BYTES }),
|
||||
bound: (input) =>
|
||||
input.callID === "call-storefail"
|
||||
? Effect.fail(new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") }))
|
||||
: Effect.succeed({ content: input.content, outputPaths: [] }),
|
||||
})
|
||||
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[LayerNodePlatform.llmClient, client],
|
||||
|
|
@ -391,7 +403,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
|||
[PermissionV2.node, permission],
|
||||
[Config.node, config],
|
||||
[McpInstructions.node, mcpInstructions],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
[ToolOutputStore.node, toolOutputStore],
|
||||
[PluginSupervisor.node, pluginSupervisor],
|
||||
])
|
||||
const execution = Layer.effect(
|
||||
|
|
@ -422,6 +434,7 @@ const it = testEffect(
|
|||
Catalog.node,
|
||||
ToolRegistry.node,
|
||||
ToolRegistry.toolsNode,
|
||||
ToolHooks.node,
|
||||
PluginHooks.node,
|
||||
echoNode,
|
||||
SessionRunnerModel.node,
|
||||
|
|
@ -449,7 +462,7 @@ const it = testEffect(
|
|||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[SessionExecution.node, execution],
|
||||
[Config.node, config],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
[ToolOutputStore.node, toolOutputStore],
|
||||
[PluginSupervisor.node, pluginSupervisor],
|
||||
],
|
||||
),
|
||||
|
|
@ -586,8 +599,8 @@ const recordedStepSettlementEvents = (id: SessionV2.ID, assistantMessageID: Sess
|
|||
const settlementTypes = new Set([
|
||||
"session.step.started.1",
|
||||
"session.tool.called.1",
|
||||
"session.tool.success.1",
|
||||
"session.tool.failed.1",
|
||||
"session.tool.success.2",
|
||||
"session.tool.failed.2",
|
||||
"session.step.ended.1",
|
||||
"session.step.failed.1",
|
||||
])
|
||||
|
|
@ -827,12 +840,26 @@ describe("SessionRunnerLLM", () => {
|
|||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
// A hook-removed call fails independently and continues while step allowance remains.
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[0]?.system.map((part) => part.text)).toEqual(["Hooked system"])
|
||||
expect(requests[0]?.messages).toEqual([Message.user("Hooked message")])
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("echo")
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("unregistered")
|
||||
expect(executions).toEqual([])
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Original message" },
|
||||
{
|
||||
type: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-removed",
|
||||
state: { status: "error", error: { type: "tool.unknown" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -841,19 +868,22 @@ describe("SessionRunnerLLM", () => {
|
|||
const session = yield* setup
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const contexts: Tool.Context[] = []
|
||||
yield* registry.register({
|
||||
location_context: Tool.make({
|
||||
description: "Read application context",
|
||||
input: Schema.Struct({ query: Schema.String }),
|
||||
output: Schema.Struct({ answer: Schema.String }),
|
||||
execute: ({ query }, context) =>
|
||||
Effect.gen(function* () {
|
||||
contexts.push(context)
|
||||
yield* context.progress({ structured: { phase: "reading" } })
|
||||
return { answer: query.toUpperCase() }
|
||||
}),
|
||||
}),
|
||||
}, { codemode: false })
|
||||
yield* registry.register(
|
||||
{
|
||||
location_context: Tool.make({
|
||||
description: "Read application context",
|
||||
input: Schema.Struct({ query: Schema.String }),
|
||||
output: Schema.Struct({ answer: Schema.String }),
|
||||
execute: ({ query }, context) =>
|
||||
Effect.gen(function* () {
|
||||
contexts.push(context)
|
||||
yield* context.progress({ phase: "reading" })
|
||||
return { output: { answer: query.toUpperCase() } }
|
||||
}),
|
||||
}),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
yield* admit(session, "Use application context")
|
||||
responses = [reply.tool("call-location", "location_context", { query: "hello" }), []]
|
||||
const events = yield* EventV2.Service
|
||||
|
|
@ -876,7 +906,7 @@ describe("SessionRunnerLLM", () => {
|
|||
progress: expect.any(Function),
|
||||
},
|
||||
])
|
||||
expect(Array.from(yield* Fiber.join(progressFiber))[0]?.data.structured).toEqual({ phase: "reading" })
|
||||
expect(Array.from(yield* Fiber.join(progressFiber))[0]?.data.metadata).toEqual({ phase: "reading" })
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Use application context" },
|
||||
{
|
||||
|
|
@ -885,7 +915,7 @@ describe("SessionRunnerLLM", () => {
|
|||
{
|
||||
type: "tool",
|
||||
id: "call-location",
|
||||
state: { status: "completed", structured: { answer: "HELLO" } },
|
||||
state: { status: "completed", content: [{ type: "text", text: '{"answer":"HELLO"}' }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -893,25 +923,29 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("persists the latest partial snapshot when a tool fails", () =>
|
||||
it.effect("prefers failure outcome metadata over retained progress", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* registry.register({
|
||||
failing_progress: Tool.make({
|
||||
description: "Report progress and fail",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: (_, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* context.progress({
|
||||
structured: { phase: "running" },
|
||||
content: [{ type: "text", text: "before failure" }],
|
||||
})
|
||||
return yield* new ToolFailure({ message: "failed after progress" })
|
||||
}),
|
||||
}),
|
||||
}, { codemode: false })
|
||||
const hooks = yield* ToolHooks.Service
|
||||
yield* hooks.hook.after((event) => {
|
||||
if (event.status === "error") event.metadata = { phase: "failed" }
|
||||
})
|
||||
yield* registry.register(
|
||||
{
|
||||
failing_progress: Tool.make({
|
||||
description: "Report progress and fail",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: (_, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* context.progress({ phase: "running" })
|
||||
return yield* new ToolFailure({ message: "failed after progress" })
|
||||
}),
|
||||
}),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
yield* admit(session, "Run failing progress")
|
||||
responses = [reply.tool("call-failing-progress", "failing_progress", {}), reply.stop()]
|
||||
|
||||
|
|
@ -927,8 +961,7 @@ describe("SessionRunnerLLM", () => {
|
|||
id: "call-failing-progress",
|
||||
state: {
|
||||
status: "error",
|
||||
structured: { phase: "running" },
|
||||
content: [{ type: "text", text: "before failure" }],
|
||||
metadata: { phase: "failed" },
|
||||
error: { message: "failed after progress" },
|
||||
},
|
||||
},
|
||||
|
|
@ -946,14 +979,20 @@ describe("SessionRunnerLLM", () => {
|
|||
const scope = yield* Scope.make()
|
||||
const executions: string[] = []
|
||||
yield* registry
|
||||
.register({
|
||||
reloaded: Tool.make({
|
||||
description: "Record the advertised tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ value: Schema.String }),
|
||||
execute: () => Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ value: "advertised" })),
|
||||
}),
|
||||
}, { codemode: false })
|
||||
.register(
|
||||
{
|
||||
reloaded: Tool.make({
|
||||
description: "Record the advertised tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ value: Schema.String }),
|
||||
execute: () =>
|
||||
Effect.sync(() => executions.push("advertised")).pipe(
|
||||
Effect.as({ output: { value: "advertised" } }),
|
||||
),
|
||||
}),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
.pipe(Scope.provide(scope))
|
||||
yield* admit(session, "Use the reloaded tool")
|
||||
responses = [
|
||||
|
|
@ -971,14 +1010,20 @@ describe("SessionRunnerLLM", () => {
|
|||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* registry.register({
|
||||
reloaded: Tool.make({
|
||||
description: "Record the replacement tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ value: Schema.String }),
|
||||
execute: () => Effect.sync(() => executions.push("replacement")).pipe(Effect.as({ value: "replacement" })),
|
||||
}),
|
||||
}, { codemode: false })
|
||||
yield* registry.register(
|
||||
{
|
||||
reloaded: Tool.make({
|
||||
description: "Record the replacement tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ value: Schema.String }),
|
||||
execute: () =>
|
||||
Effect.sync(() => executions.push("replacement")).pipe(
|
||||
Effect.as({ output: { value: "replacement" } }),
|
||||
),
|
||||
}),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
yield* Fiber.join(run)
|
||||
|
||||
|
|
@ -991,7 +1036,7 @@ describe("SessionRunnerLLM", () => {
|
|||
{
|
||||
type: "tool",
|
||||
id: "call-reloaded",
|
||||
state: { status: "completed", structured: { value: "advertised" } },
|
||||
state: { status: "completed", content: [{ type: "text", text: '{"value":"advertised"}' }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -2377,7 +2422,6 @@ describe("SessionRunnerLLM", () => {
|
|||
state: {
|
||||
status: "completed",
|
||||
input: { query: "hello" },
|
||||
structured: {},
|
||||
content: [
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "file", mime: "image/png", uri: "data:image/png;base64,aGVsbG8=", name: "hello.png" },
|
||||
|
|
@ -2417,7 +2461,6 @@ describe("SessionRunnerLLM", () => {
|
|||
state: {
|
||||
status: "completed",
|
||||
input: { text: "hello" },
|
||||
structured: { text: "hello" },
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
},
|
||||
|
|
@ -2429,7 +2472,7 @@ describe("SessionRunnerLLM", () => {
|
|||
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
|
||||
"session.step.started.1",
|
||||
"session.tool.called.1",
|
||||
"session.tool.success.1",
|
||||
"session.tool.success.2",
|
||||
"session.step.ended.1",
|
||||
])
|
||||
}),
|
||||
|
|
@ -2581,7 +2624,8 @@ describe("SessionRunnerLLM", () => {
|
|||
type: "tool-result",
|
||||
id: "hosted-search",
|
||||
name: "web_search",
|
||||
result: { type: "json", value: [{ title: "Effect" }] },
|
||||
// The generic replay result derives from canonical stored content.
|
||||
result: { type: "text", value: '[{"title":"Effect"}]' },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { blockType: "web_search_tool_result" } },
|
||||
},
|
||||
|
|
@ -2667,7 +2711,7 @@ describe("SessionRunnerLLM", () => {
|
|||
{
|
||||
type: "tool",
|
||||
id: "tool_0",
|
||||
state: { status: "completed", structured: { text: "first" }, content: [{ type: "text", text: "first" }] },
|
||||
state: { status: "completed", content: [{ type: "text", text: "first" }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -2677,11 +2721,7 @@ describe("SessionRunnerLLM", () => {
|
|||
{
|
||||
type: "tool",
|
||||
id: "tool_0",
|
||||
state: {
|
||||
status: "completed",
|
||||
structured: { text: "second" },
|
||||
content: [{ type: "text", text: "second" }],
|
||||
},
|
||||
state: { status: "completed", content: [{ type: "text", text: "second" }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -2697,7 +2737,7 @@ describe("SessionRunnerLLM", () => {
|
|||
{
|
||||
type: "tool",
|
||||
id: "tool_0",
|
||||
state: { status: "completed", structured: { text: "first" }, content: [{ type: "text", text: "first" }] },
|
||||
state: { status: "completed", content: [{ type: "text", text: "first" }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -2707,11 +2747,7 @@ describe("SessionRunnerLLM", () => {
|
|||
{
|
||||
type: "tool",
|
||||
id: "tool_0",
|
||||
state: {
|
||||
status: "completed",
|
||||
structured: { text: "second" },
|
||||
content: [{ type: "text", text: "second" }],
|
||||
},
|
||||
state: { status: "completed", content: [{ type: "text", text: "second" }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -3404,7 +3440,7 @@ describe("SessionRunnerLLM", () => {
|
|||
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
|
||||
"session.step.started.1",
|
||||
"session.tool.called.1",
|
||||
"session.tool.failed.1",
|
||||
"session.tool.failed.2",
|
||||
"session.step.ended.1",
|
||||
])
|
||||
}),
|
||||
|
|
@ -3414,17 +3450,20 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* registry.register({
|
||||
blocked: Tool.make({
|
||||
description: "Fail because policy blocked execution",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: () =>
|
||||
Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "blocked", resources: ["*"] })).pipe(
|
||||
Effect.mapError(() => new Tool.Failure({ message: "Permission blocked" })),
|
||||
),
|
||||
}),
|
||||
}, { codemode: false })
|
||||
yield* registry.register(
|
||||
{
|
||||
blocked: Tool.make({
|
||||
description: "Fail because policy blocked execution",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: () =>
|
||||
Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "blocked", resources: ["*"] })).pipe(
|
||||
Effect.mapError(() => new Tool.Failure({ message: "Permission blocked" })),
|
||||
),
|
||||
}),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
yield* admit(session, "Call blocked")
|
||||
|
||||
responses = [reply.tool("call-blocked", "blocked", {}), reply.stop()]
|
||||
|
|
@ -3449,14 +3488,17 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* registry.register({
|
||||
declined: Tool.make({
|
||||
description: "Fail because the user declined approval",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.die(new PermissionV2.DeclinedError()),
|
||||
}),
|
||||
}, { codemode: false })
|
||||
yield* registry.register(
|
||||
{
|
||||
declined: Tool.make({
|
||||
description: "Fail because the user declined approval",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.die(new PermissionV2.DeclinedError()),
|
||||
}),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
yield* admit(session, "Call declined")
|
||||
|
||||
response = reply.tool("call-declined", "declined", {})
|
||||
|
|
@ -3486,17 +3528,20 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* registry.register({
|
||||
corrected: Tool.make({
|
||||
description: "Fail with user correction feedback",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: () =>
|
||||
Effect.fail(new PermissionV2.CorrectedError({ feedback: "Use another tool" })).pipe(
|
||||
Effect.mapError(() => new Tool.Failure({ message: "Use another tool" })),
|
||||
),
|
||||
}),
|
||||
}, { codemode: false })
|
||||
yield* registry.register(
|
||||
{
|
||||
corrected: Tool.make({
|
||||
description: "Fail with user correction feedback",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: () =>
|
||||
Effect.fail(new PermissionV2.CorrectedError({ feedback: "Use another tool" })).pipe(
|
||||
Effect.mapError(() => new Tool.Failure({ message: "Use another tool" })),
|
||||
),
|
||||
}),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
yield* admit(session, "Call corrected")
|
||||
|
||||
responses = [reply.tool("call-corrected", "corrected", {}), reply.stop()]
|
||||
|
|
@ -3540,13 +3585,13 @@ describe("SessionRunnerLLM", () => {
|
|||
status: "error",
|
||||
error: {
|
||||
type: "unknown",
|
||||
message: expect.stringContaining("Failed to encode tool output"),
|
||||
message: expect.stringContaining("Failed to write tool output"),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
finish: "error",
|
||||
error: { type: "unknown", message: expect.stringContaining("Failed to encode tool output") },
|
||||
error: { type: "unknown", message: expect.stringContaining("Failed to write tool output") },
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
|
@ -3594,14 +3639,17 @@ describe("SessionRunnerLLM", () => {
|
|||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* registry.register({
|
||||
question: Tool.make({
|
||||
description: "Ask the user",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.die(new QuestionTool.CancelledError()),
|
||||
}),
|
||||
}, { codemode: false })
|
||||
yield* registry.register(
|
||||
{
|
||||
question: Tool.make({
|
||||
description: "Ask the user",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.die(new QuestionTool.CancelledError()),
|
||||
}),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
yield* admit(session, "Ask then stop")
|
||||
|
||||
responses = [reply.tool("call-question", "question", {}), []]
|
||||
|
|
@ -3655,7 +3703,11 @@ describe("SessionRunnerLLM", () => {
|
|||
{
|
||||
type: "assistant",
|
||||
content: [
|
||||
{ type: "tool", id: "call-before-failure", state: { status: "completed", structured: { text: "settle" } } },
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-before-failure",
|
||||
state: { status: "completed", content: [{ type: "text", text: "settle" }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
|
@ -3663,7 +3715,7 @@ describe("SessionRunnerLLM", () => {
|
|||
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
|
||||
"session.step.started.1",
|
||||
"session.tool.called.1",
|
||||
"session.tool.success.1",
|
||||
"session.tool.success.2",
|
||||
"session.step.failed.1",
|
||||
])
|
||||
}),
|
||||
|
|
@ -3707,7 +3759,7 @@ describe("SessionRunnerLLM", () => {
|
|||
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
|
||||
"session.step.started.1",
|
||||
"session.tool.called.1",
|
||||
"session.tool.failed.1",
|
||||
"session.tool.failed.2",
|
||||
"session.step.failed.1",
|
||||
])
|
||||
|
||||
|
|
@ -3808,7 +3860,8 @@ describe("SessionRunnerLLM", () => {
|
|||
expect(requests).toHaveLength(2)
|
||||
expect(requests[0]?.toolChoice).toBeUndefined()
|
||||
expect(requests[1]?.toolChoice).toMatchObject({ type: "none" })
|
||||
expect(requests[1]?.tools).toEqual([])
|
||||
// Protocols with native "none" keep these definitions for prompt caching.
|
||||
expect(requests[1]?.tools.map((tool) => tool.name)).toContain("echo")
|
||||
expect(requests[1]?.messages.at(-1)).toMatchObject({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }],
|
||||
|
|
@ -3953,7 +4006,7 @@ describe("SessionRunnerLLM", () => {
|
|||
expect(events.map((event) => event.type)).toEqual([
|
||||
"session.step.started.1",
|
||||
"session.tool.called.1",
|
||||
"session.tool.success.1",
|
||||
"session.tool.success.2",
|
||||
"session.step.failed.1",
|
||||
])
|
||||
expect(
|
||||
|
|
@ -4146,7 +4199,8 @@ describe("SessionRunnerLLM", () => {
|
|||
content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }],
|
||||
})
|
||||
expect(requests[2]?.toolChoice).toMatchObject({ type: "none" })
|
||||
expect(requests[2]?.tools).toEqual([])
|
||||
// The final step keeps tool definitions to preserve provider prompt caching.
|
||||
expect(requests[2]?.tools.map((tool) => tool.name)).toContain("echo")
|
||||
expect(requests[2]?.messages.at(-1)).toMatchObject({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }],
|
||||
|
|
@ -4197,7 +4251,7 @@ describe("SessionRunnerLLM", () => {
|
|||
expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([
|
||||
{ type: "session.step.started.1" },
|
||||
{
|
||||
type: "session.tool.failed.1",
|
||||
type: "session.tool.failed.2",
|
||||
data: {
|
||||
callID: "call-malformed",
|
||||
error: { type: "provider.invalid-output", message: "Invalid JSON input for tool call echo" },
|
||||
|
|
@ -4292,7 +4346,7 @@ describe("SessionRunnerLLM", () => {
|
|||
expect(failed.error).toBeUndefined()
|
||||
expect((yield* recordedStepSettlementEvents(sessionID, failed.id)).map((event) => event.type)).toEqual([
|
||||
"session.step.started.1",
|
||||
"session.tool.failed.1",
|
||||
"session.tool.failed.2",
|
||||
"session.step.ended.1",
|
||||
])
|
||||
const database = (yield* Database.Service).db
|
||||
|
|
@ -4521,7 +4575,7 @@ describe("SessionRunnerLLM", () => {
|
|||
expect(requests).toHaveLength(2)
|
||||
expect(requests[0]?.toolChoice).toBeUndefined()
|
||||
expect(requests[1]?.toolChoice).toMatchObject({ type: "none" })
|
||||
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.tool.failed.1")).toHaveLength(2)
|
||||
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.tool.failed.2")).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -4553,7 +4607,7 @@ describe("SessionRunnerLLM", () => {
|
|||
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
|
||||
"session.step.started.1",
|
||||
"session.tool.called.1",
|
||||
"session.tool.success.1",
|
||||
"session.tool.success.2",
|
||||
"session.step.failed.1",
|
||||
])
|
||||
}),
|
||||
|
|
@ -4585,7 +4639,7 @@ describe("SessionRunnerLLM", () => {
|
|||
expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([
|
||||
"session.step.started.1",
|
||||
"session.tool.called.1",
|
||||
"session.tool.failed.1",
|
||||
"session.tool.failed.2",
|
||||
"session.step.failed.1",
|
||||
])
|
||||
}),
|
||||
|
|
@ -4609,7 +4663,7 @@ describe("SessionRunnerLLM", () => {
|
|||
expect(events.map((event) => event.type)).toEqual([
|
||||
"session.step.started.1",
|
||||
"session.tool.called.1",
|
||||
"session.tool.failed.1",
|
||||
"session.tool.failed.2",
|
||||
"session.step.failed.1",
|
||||
])
|
||||
expect(events[2]?.data.error).toMatchObject({ type: "unknown", message: "unexpected tool defect" })
|
||||
|
|
@ -4646,7 +4700,7 @@ describe("SessionRunnerLLM", () => {
|
|||
expect(events.map((event) => event.type)).toEqual([
|
||||
"session.step.started.1",
|
||||
"session.tool.called.1",
|
||||
"session.tool.failed.1",
|
||||
"session.tool.failed.2",
|
||||
"session.step.failed.1",
|
||||
])
|
||||
expect(
|
||||
|
|
@ -4684,7 +4738,7 @@ describe("SessionRunnerLLM", () => {
|
|||
expect(events.map((event) => event.type)).toEqual([
|
||||
"session.step.started.1",
|
||||
"session.tool.called.1",
|
||||
"session.tool.failed.1",
|
||||
"session.tool.failed.2",
|
||||
"session.step.ended.1",
|
||||
])
|
||||
expect(
|
||||
|
|
@ -4721,8 +4775,8 @@ describe("SessionRunnerLLM", () => {
|
|||
{ type: "session.step.started.1", callID: undefined },
|
||||
{ type: "session.tool.called.1", callID: "call-local-raw-failure" },
|
||||
{ type: "session.tool.called.1", callID: "call-hosted-raw-failure-pair" },
|
||||
{ type: "session.tool.failed.1", callID: "call-local-raw-failure" },
|
||||
{ type: "session.tool.failed.1", callID: "call-hosted-raw-failure-pair" },
|
||||
{ type: "session.tool.failed.2", callID: "call-local-raw-failure" },
|
||||
{ type: "session.tool.failed.2", callID: "call-hosted-raw-failure-pair" },
|
||||
{ type: "session.step.failed.1", callID: undefined },
|
||||
])
|
||||
expect(
|
||||
|
|
@ -4748,7 +4802,7 @@ describe("SessionRunnerLLM", () => {
|
|||
expect(events.map((event) => event.type)).toEqual([
|
||||
"session.step.started.1",
|
||||
"session.tool.called.1",
|
||||
"session.tool.failed.1",
|
||||
"session.tool.failed.2",
|
||||
"session.step.failed.1",
|
||||
])
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -84,30 +84,29 @@ describe("Tool.Progress", () => {
|
|||
|
||||
yield* start("call-success")
|
||||
expect((yield* readAssistant).content[0]).toMatchObject({
|
||||
state: { status: "running", structured: {}, content: [] },
|
||||
state: { status: "running", metadata: {} },
|
||||
})
|
||||
|
||||
const progress = yield* service.publish(SessionEvent.Tool.Progress, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
callID: "call-success",
|
||||
structured: { phase: "checkpoint" },
|
||||
content: content("saved"),
|
||||
metadata: { phase: "checkpoint" },
|
||||
})
|
||||
expect((yield* readAssistant).content[0]).toMatchObject({
|
||||
state: { status: "running", structured: {}, content: [] },
|
||||
state: { status: "running", metadata: {} },
|
||||
})
|
||||
|
||||
const success = yield* service.publish(SessionEvent.Tool.Success, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
callID: "call-success",
|
||||
structured: { phase: "done" },
|
||||
metadata: { phase: "done" },
|
||||
content: content("complete"),
|
||||
executed: false,
|
||||
})
|
||||
expect((yield* readAssistant).content[0]).toMatchObject({
|
||||
state: { status: "completed", structured: { phase: "done" }, content: content("complete") },
|
||||
state: { status: "completed", metadata: { phase: "done" }, content: content("complete") },
|
||||
})
|
||||
|
||||
yield* start("call-failed")
|
||||
|
|
@ -115,8 +114,7 @@ describe("Tool.Progress", () => {
|
|||
sessionID,
|
||||
assistantMessageID,
|
||||
callID: "call-failed",
|
||||
structured: { phase: "checkpoint" },
|
||||
content: content("before failure"),
|
||||
metadata: { phase: "checkpoint" },
|
||||
})
|
||||
const failed = yield* service.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID,
|
||||
|
|
@ -130,7 +128,7 @@ describe("Tool.Progress", () => {
|
|||
expect((yield* readAssistant).content[1]).toMatchObject({
|
||||
state: {
|
||||
status: "error",
|
||||
structured: { phase: "checkpoint" },
|
||||
metadata: { phase: "checkpoint" },
|
||||
content: content("before failure"),
|
||||
error: { type: "unknown", message: "boom" },
|
||||
},
|
||||
|
|
@ -147,8 +145,8 @@ describe("Tool.Progress", () => {
|
|||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
expect(rows.map((row) => row.type)).not.toContain(EventV2.versionedType(SessionEvent.Tool.Progress.type, 1))
|
||||
expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Success.type, 1))
|
||||
expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Failed.type, 1))
|
||||
expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Success.type, 2))
|
||||
expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Failed.type, 2))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { location } from "./fixture/location"
|
|||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const editToolNode = makeLocationNode({
|
||||
name: "test/edit-tool-plugin",
|
||||
|
|
@ -141,15 +141,23 @@ describe("EditTool", () => {
|
|||
expect(yield* toolDefinitions(registry, [{ action: "edit", resource: "*", effect: "deny" }])).toEqual(
|
||||
[],
|
||||
)
|
||||
const settled = yield* settleTool(
|
||||
const settled = yield* executeTool(
|
||||
registry,
|
||||
call({ path: "hello.txt", oldString: "before", newString: "after" }),
|
||||
)
|
||||
expect(settled.result).toEqual({
|
||||
type: "text",
|
||||
value: "Edited file successfully: hello.txt\nReplacements: 1\n```diff\n-before\n+after\n```",
|
||||
expect(settled.status).toBe("completed")
|
||||
if (settled.status !== "completed") return
|
||||
expect(settled.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Edited file successfully: hello.txt\nReplacements: 1\n```diff\n-before\n+after\n```",
|
||||
},
|
||||
])
|
||||
// Compact UI metadata carries the file diffs the TUI renders.
|
||||
expect(settled.metadata).toMatchObject({
|
||||
files: [{ file: "hello.txt", status: "modified", additions: 1, deletions: 1 }],
|
||||
})
|
||||
expect(settled.output?.structured).toEqual({
|
||||
expect(settled.output).toEqual({
|
||||
replacements: 1,
|
||||
files: [
|
||||
{
|
||||
|
|
@ -187,7 +195,7 @@ describe("EditTool", () => {
|
|||
),
|
||||
Effect.andThen((result) =>
|
||||
Effect.gen(function* () {
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.status).toBe("completed")
|
||||
expect(assertions.map((input) => input.action)).toEqual(["edit"])
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after")
|
||||
}),
|
||||
|
|
@ -217,7 +225,7 @@ describe("EditTool", () => {
|
|||
),
|
||||
Effect.andThen((result) =>
|
||||
Effect.sync(() => {
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.status).toBe("completed")
|
||||
expect(assertions.map((input) => input.action)).toEqual(["edit"])
|
||||
expect(assertions[0]?.resources).toEqual(["link.txt"])
|
||||
}),
|
||||
|
|
@ -247,7 +255,7 @@ describe("EditTool", () => {
|
|||
),
|
||||
Effect.andThen((result) =>
|
||||
Effect.gen(function* () {
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.status).toBe("completed")
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after")
|
||||
expect(writes).toHaveLength(1)
|
||||
|
|
@ -276,8 +284,8 @@ describe("EditTool", () => {
|
|||
executeTool(registry, call({ path: external, oldString: "before", newString: "after" })),
|
||||
),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value: `Unable to edit ${external}`,
|
||||
status: "error",
|
||||
error: { type: "permission.rejected", message: "Permission denied: external_directory" },
|
||||
})
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory"])
|
||||
expect(reads).toBe(0)
|
||||
|
|
@ -290,8 +298,8 @@ describe("EditTool", () => {
|
|||
executeTool(registry, call({ path: external, oldString: "before", newString: "after" })),
|
||||
),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value: `Unable to edit ${external}`,
|
||||
status: "error",
|
||||
error: { type: "permission.rejected", message: "Permission denied: edit" },
|
||||
})
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
||||
expect(reads).toBe(0)
|
||||
|
|
@ -325,7 +333,10 @@ describe("EditTool", () => {
|
|||
call({ path: "secret.txt", oldString: "not present", newString: "replacement" }),
|
||||
)
|
||||
|
||||
expect(matching).toEqual({ type: "error", value: "Unable to edit secret.txt" })
|
||||
expect(matching).toEqual({
|
||||
status: "error",
|
||||
error: { type: "permission.rejected", message: "Permission denied: edit" },
|
||||
})
|
||||
expect(missing).toEqual(matching)
|
||||
expect(assertions.map((input) => input.action)).toEqual(["edit", "edit"])
|
||||
expect(reads).toBe(0)
|
||||
|
|
@ -352,28 +363,40 @@ describe("EditTool", () => {
|
|||
expect(
|
||||
yield* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "same" })),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value: "No changes to apply: oldString and newString are identical.",
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: "No changes to apply: oldString and newString are identical.",
|
||||
},
|
||||
})
|
||||
expect(
|
||||
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.",
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: "oldString must not be empty. Use write to create or overwrite a file.",
|
||||
},
|
||||
})
|
||||
expect(
|
||||
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.",
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message:
|
||||
"Could not find oldString in the file. It must match exactly, including whitespace and indentation.",
|
||||
},
|
||||
})
|
||||
expect(
|
||||
yield* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "after" })),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value:
|
||||
"Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.",
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message:
|
||||
"Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.",
|
||||
},
|
||||
})
|
||||
expect(writes).toEqual([])
|
||||
}),
|
||||
|
|
@ -394,12 +417,14 @@ describe("EditTool", () => {
|
|||
return Effect.promise(() => fs.writeFile(target, "same same same")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
settleTool(registry, call({ path: "all.txt", oldString: "same", newString: "after", replaceAll: true })),
|
||||
executeTool(registry, call({ path: "all.txt", oldString: "same", newString: "after", replaceAll: true })),
|
||||
),
|
||||
),
|
||||
Effect.andThen((settled) =>
|
||||
Effect.gen(function* () {
|
||||
expect(settled.output?.structured).toMatchObject({ replacements: 3 })
|
||||
expect(settled.status).toBe("completed")
|
||||
if (settled.status !== "completed") return
|
||||
expect(settled.output).toMatchObject({ replacements: 3 })
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after after after")
|
||||
expect(writes).toHaveLength(1)
|
||||
}),
|
||||
|
|
@ -445,9 +470,14 @@ describe("EditTool", () => {
|
|||
),
|
||||
Effect.andThen((result) =>
|
||||
Effect.gen(function* () {
|
||||
// The message-less StaleContentError cause must not erase the tool's
|
||||
// curated failure message; the canonical error is the sole authority.
|
||||
expect(result).toEqual({
|
||||
type: "error",
|
||||
value: "File changed after permission approval. Read it again before editing.",
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: "File changed after permission approval. Read it again before editing.",
|
||||
},
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("newer\n")
|
||||
expect(writes).toEqual([])
|
||||
|
|
|
|||
|
|
@ -6,6 +6,69 @@ import { Session } from "@opencode-ai/schema/session"
|
|||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
const context = {
|
||||
sessionID: Session.ID.make("ses_execute"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_execute"),
|
||||
callID: "call_execute",
|
||||
progress: () => Effect.void,
|
||||
}
|
||||
|
||||
test("canonical execution distinguishes declared, model-only, and raw schema outputs", async () => {
|
||||
const declared = Tool.make({
|
||||
description: "Declared",
|
||||
input: Schema.Struct({ value: Schema.String }),
|
||||
output: Schema.Struct({ value: Schema.String }),
|
||||
execute: ({ value }) => Effect.succeed({ output: { value } }),
|
||||
})
|
||||
const modelOnly = Tool.make({
|
||||
description: "Model only",
|
||||
input: Schema.Struct({}),
|
||||
execute: () => Effect.succeed({ content: "visible only", metadata: { kind: "model" } }),
|
||||
})
|
||||
const raw = Tool.make({
|
||||
description: "Raw",
|
||||
input: {},
|
||||
output: {},
|
||||
execute: (input) => Effect.succeed({ output: input, content: "raw" }),
|
||||
})
|
||||
|
||||
expect(await Effect.runPromise(Tool.execute(declared, { value: "encoded" }, context))).toEqual({
|
||||
output: { value: "encoded" },
|
||||
content: [{ type: "text", text: '{"value":"encoded"}' }],
|
||||
})
|
||||
expect(await Effect.runPromise(Tool.execute(modelOnly, {}, context))).toEqual({
|
||||
content: [{ type: "text", text: "visible only" }],
|
||||
metadata: { kind: "model" },
|
||||
})
|
||||
expect(await Effect.runPromise(Tool.execute(raw, { unchecked: true }, context))).toEqual({
|
||||
output: { unchecked: true },
|
||||
content: [{ type: "text", text: "raw" }],
|
||||
})
|
||||
})
|
||||
|
||||
test("declared outputs cannot bypass validation and raw outputs stay JSON-compatible", async () => {
|
||||
const missing: Tool.Any = {
|
||||
description: "Missing output",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed({ content: "not an output" }),
|
||||
}
|
||||
const invalid: Tool.Any = {
|
||||
description: "Invalid raw output",
|
||||
input: {},
|
||||
output: {},
|
||||
execute: () => Effect.succeed({ output: 1n, content: "not JSON" }),
|
||||
}
|
||||
|
||||
expect((await Effect.runPromiseExit(Tool.execute(missing, {}, context))).toString()).toContain(
|
||||
"Tool did not return its declared output",
|
||||
)
|
||||
expect((await Effect.runPromiseExit(Tool.execute(invalid, {}, context))).toString()).toContain(
|
||||
"Tool returned a non-JSON value",
|
||||
)
|
||||
})
|
||||
|
||||
test("execute preserves successful results with visible unhandled rejections", async () => {
|
||||
const child = Tool.make({
|
||||
description: "Always fail",
|
||||
|
|
@ -13,27 +76,10 @@ test("execute preserves successful results with visible unhandled rejections", a
|
|||
output: Schema.String,
|
||||
execute: () => Effect.fail(new Tool.Failure({ message: "Lookup refused" })),
|
||||
})
|
||||
const execute = ExecuteTool.create(new Map([["fail", { tool: child, name: "fail" }]]))
|
||||
const result = await Effect.runPromise(
|
||||
Tool.settle(
|
||||
execute,
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "call_execute",
|
||||
name: "execute",
|
||||
input: { code: `tools.fail({}); return "done"` },
|
||||
},
|
||||
{
|
||||
sessionID: Session.ID.make("ses_execute"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_execute"),
|
||||
callID: "call_execute",
|
||||
progress: () => Effect.void,
|
||||
},
|
||||
),
|
||||
)
|
||||
const execute = ExecuteTool.create(new Map([["fail", { tool: child, name: "fail", permission: "fail" }]]))
|
||||
const result = await Effect.runPromise(Tool.execute(execute, { code: `tools.fail({}); return "done"` }, context))
|
||||
|
||||
expect(result.structured).toEqual({ toolCalls: [{ tool: "fail", status: "error" }] })
|
||||
expect(result.metadata).toEqual({ toolCalls: [{ tool: "fail", status: "error" }] })
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
|
|
@ -52,40 +98,32 @@ test("execute supports callable namespace tools", async () => {
|
|||
description: "Administer Slack",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("admin"),
|
||||
execute: () => Effect.succeed({ output: "admin" }),
|
||||
})
|
||||
const child = Tool.make({
|
||||
description: "Create a Slack resource",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("created"),
|
||||
execute: () => Effect.succeed({ output: "created" }),
|
||||
})
|
||||
const execute = ExecuteTool.create(
|
||||
new Map([
|
||||
["slack_admin", { tool: callable, name: "admin", namespace: "slack" }],
|
||||
["slack_admin_create", { tool: child, name: "create", namespace: "slack.admin" }],
|
||||
["slack_admin", { tool: callable, name: "admin", namespace: "slack", permission: "slack_admin" }],
|
||||
[
|
||||
"slack_admin_create",
|
||||
{ tool: child, name: "create", namespace: "slack.admin", permission: "slack_admin_create" },
|
||||
],
|
||||
]),
|
||||
)
|
||||
const result = await Effect.runPromise(
|
||||
Tool.settle(
|
||||
Tool.execute(
|
||||
execute,
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "call_execute",
|
||||
name: "execute",
|
||||
input: { code: "return [await tools.slack.admin({}), await tools.slack.admin.create({})]" },
|
||||
},
|
||||
{
|
||||
sessionID: Session.ID.make("ses_execute"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_execute"),
|
||||
callID: "call_execute",
|
||||
progress: () => Effect.void,
|
||||
},
|
||||
{ code: "return [await tools.slack.admin({}), await tools.slack.admin.create({})]" },
|
||||
context,
|
||||
),
|
||||
)
|
||||
|
||||
expect(result.structured).toEqual({
|
||||
expect(result.metadata).toEqual({
|
||||
toolCalls: [
|
||||
{ tool: "slack.admin", status: "completed" },
|
||||
{ tool: "slack.admin.create", status: "completed" },
|
||||
|
|
|
|||
|
|
@ -53,52 +53,31 @@ describe("ToolOutputStore", () => {
|
|||
const result = yield* store.bound({
|
||||
sessionID,
|
||||
callID: "call-aggregate",
|
||||
output: {
|
||||
structured: { kind: "report" },
|
||||
content: [
|
||||
{ type: "text", text: first },
|
||||
{ type: "text", text: second },
|
||||
],
|
||||
},
|
||||
content: [
|
||||
{ type: "text", text: first },
|
||||
{ type: "text", text: second },
|
||||
],
|
||||
})
|
||||
expect(result.output.structured).toEqual({ kind: "report" })
|
||||
expect(result.outputPaths).toHaveLength(1)
|
||||
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)
|
||||
if (result.content[0]?.type !== "text") throw new Error("expected text preview")
|
||||
expect(Buffer.byteLength(result.content[0].text)).toBeLessThanOrEqual(ToolOutputStore.MAX_BYTES)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("uses bounded text for oversized structured-only output", () =>
|
||||
withStore(({ store, fs }) =>
|
||||
Effect.gen(function* () {
|
||||
const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) }
|
||||
const result = yield* store.bound({ sessionID, callID: "call-json", output: { structured, content: [] } })
|
||||
expect(result.output.structured).toEqual(structured)
|
||||
expect(result.outputPaths).toHaveLength(1)
|
||||
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured)
|
||||
expect(result.output.content).toHaveLength(1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves native media and structured metadata without applying a settlement media limit", () =>
|
||||
it.live("preserves native media without applying an execution media limit", () =>
|
||||
withStore(({ store }) =>
|
||||
Effect.gen(function* () {
|
||||
const data = "a".repeat(6 * 1024 * 1024)
|
||||
const result = yield* store.bound({
|
||||
sessionID,
|
||||
callID: "call-file",
|
||||
output: {
|
||||
structured: { caption: "pixel" },
|
||||
content: [{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "pixel.png" }],
|
||||
},
|
||||
content: [{ type: "file", uri: `data:image/png;base64,${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({
|
||||
expect(result.content).toHaveLength(1)
|
||||
expect(result.content[0]).toEqual({
|
||||
type: "file",
|
||||
uri: `data:image/png;base64,${data}`,
|
||||
mime: "image/png",
|
||||
|
|
@ -108,7 +87,7 @@ describe("ToolOutputStore", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("preserves structured metadata and native media when bounding text", () =>
|
||||
it.live("preserves native media when bounding text", () =>
|
||||
withStore(({ store, fs }) =>
|
||||
Effect.gen(function* () {
|
||||
const text = "x".repeat(ToolOutputStore.MAX_BYTES + 1)
|
||||
|
|
@ -121,30 +100,29 @@ describe("ToolOutputStore", () => {
|
|||
const result = yield* store.bound({
|
||||
sessionID,
|
||||
callID: "call-text-and-media",
|
||||
output: { structured: { caption: "pixel" }, content: [{ type: "text", text }, media] },
|
||||
content: [{ type: "text", text }, media],
|
||||
})
|
||||
|
||||
expect(result.output.structured).toEqual({ caption: "pixel" })
|
||||
expect(result.output.content[1]).toEqual(media)
|
||||
expect(result.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", () =>
|
||||
it.live("returns content within the limits unchanged", () =>
|
||||
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, callID: "call-duplicated", output })).toEqual({
|
||||
output,
|
||||
const content = [{ type: "text" as const, text }]
|
||||
expect(yield* store.bound({ sessionID, callID: "call-duplicated", content })).toEqual({
|
||||
content,
|
||||
outputPaths: [],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("fails oversized settlement when complete retention cannot be written", () =>
|
||||
it.live("fails oversized execution when complete retention cannot be written", () =>
|
||||
withStore(({ root, store, fs }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* fs.writeFileString(path.join(root, "tool-output"), "not a directory")
|
||||
|
|
@ -152,7 +130,7 @@ describe("ToolOutputStore", () => {
|
|||
.bound({
|
||||
sessionID,
|
||||
callID: "call-lossy",
|
||||
output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
|
||||
content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }],
|
||||
})
|
||||
.pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
|
|
@ -162,18 +140,6 @@ describe("ToolOutputStore", () => {
|
|||
),
|
||||
)
|
||||
|
||||
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, callID: "call-unencodable", output })).toEqual({
|
||||
output,
|
||||
outputPaths: [],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves interruption while retaining complete output", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.promise(() => tmpdir())
|
||||
|
|
@ -198,7 +164,7 @@ describe("ToolOutputStore", () => {
|
|||
.bound({
|
||||
sessionID,
|
||||
callID: "call-interrupted",
|
||||
output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
|
||||
content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }],
|
||||
})
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Fiber.interrupt(fiber)
|
||||
|
|
@ -217,7 +183,7 @@ describe("ToolOutputStore", () => {
|
|||
const result = yield* store.bound({
|
||||
sessionID,
|
||||
callID: "call-config",
|
||||
output: { structured: {}, content: [{ type: "text", text: "one\ntwo\nthree" }] },
|
||||
content: [{ type: "text", text: "one\ntwo\nthree" }],
|
||||
})
|
||||
expect(result.outputPaths).toHaveLength(1)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { location } from "./fixture/location"
|
|||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const patchToolNode = makeLocationNode({
|
||||
name: "test/patch-tool-plugin",
|
||||
|
|
@ -96,29 +96,19 @@ const withTool = <A, E, R>(
|
|||
const activeLocation = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(projectDirectory) },
|
||||
),
|
||||
location({ directory: AbsolutePath.make(directory) }, { projectDirectory: AbsolutePath.make(projectDirectory) }),
|
||||
),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
return yield* body(yield* ToolRegistry.Service)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
ToolRegistry.node,
|
||||
ToolRegistry.toolsNode,
|
||||
patchToolNode,
|
||||
]),
|
||||
[
|
||||
[FSUtil.node, filesystem],
|
||||
[Location.node, activeLocation],
|
||||
[PermissionV2.node, permission],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
],
|
||||
),
|
||||
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, patchToolNode]), [
|
||||
[FSUtil.node, filesystem],
|
||||
[Location.node, activeLocation],
|
||||
[PermissionV2.node, permission],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
]),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -162,18 +152,23 @@ describe("PatchTool", () => {
|
|||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["patch"])
|
||||
const settled = yield* settleTool(
|
||||
const settled = yield* executeTool(
|
||||
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",
|
||||
),
|
||||
)
|
||||
expect(settled.result).toEqual({
|
||||
type: "text",
|
||||
value: "Success. Updated the following files:\nA nested/new.txt\nM update.txt\nD remove.txt",
|
||||
})
|
||||
if (process.platform === "win32") expect(settled.result.value).not.toContain("\\")
|
||||
expect(settled.output?.structured).toMatchObject({
|
||||
expect(settled.status).toBe("completed")
|
||||
if (settled.status !== "completed") return
|
||||
expect(settled.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Success. Updated the following files:\nA nested/new.txt\nM update.txt\nD remove.txt",
|
||||
},
|
||||
])
|
||||
const modelText = settled.content[0]?.type === "text" ? settled.content[0].text : ""
|
||||
if (process.platform === "win32") expect(modelText).not.toContain("\\")
|
||||
expect(settled.output).toMatchObject({
|
||||
applied: [
|
||||
{ type: "add", resource: "nested/new.txt" },
|
||||
{ type: "update", resource: "update.txt" },
|
||||
|
|
@ -248,9 +243,11 @@ describe("PatchTool", () => {
|
|||
"*** 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",
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
type: "text",
|
||||
value: "Success. Updated the following files:\nA created.txt\nM moved.txt",
|
||||
).toMatchObject({
|
||||
status: "completed",
|
||||
content: [
|
||||
{ type: "text", text: "Success. Updated the following files:\nA created.txt\nM moved.txt" },
|
||||
],
|
||||
})
|
||||
expect(yield* exists(source)).toBe(false)
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "moved.txt"), "utf8"))).toBe(
|
||||
|
|
@ -278,7 +275,9 @@ describe("PatchTool", () => {
|
|||
return Effect.promise(() =>
|
||||
Promise.all([
|
||||
fs.writeFile(source, "before\n"),
|
||||
fs.mkdir(path.dirname(destination), { recursive: true }).then(() => fs.writeFile(destination, "existing\n")),
|
||||
fs
|
||||
.mkdir(path.dirname(destination), { recursive: true })
|
||||
.then(() => fs.writeFile(destination, "existing\n")),
|
||||
]),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
|
|
@ -291,7 +290,7 @@ describe("PatchTool", () => {
|
|||
"*** Begin Patch\n*** Update File: old.txt\n*** Move to: nested/moved.txt\n@@\n-before\n+after\n*** End Patch",
|
||||
),
|
||||
),
|
||||
).toMatchObject({ type: "text" })
|
||||
).toMatchObject({ status: "completed" })
|
||||
expect(yield* exists(source)).toBe(false)
|
||||
expect(yield* Effect.promise(() => fs.readFile(destination, "utf8"))).toBe("after\n")
|
||||
}),
|
||||
|
|
@ -325,19 +324,21 @@ describe("PatchTool", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("includes move file info in structured output", () =>
|
||||
it.live("includes move file info in output and metadata", () =>
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(directory, "old", "name.txt")
|
||||
yield* Effect.promise(() => fs.mkdir(path.dirname(source), { recursive: true }))
|
||||
yield* Effect.promise(() => fs.writeFile(source, "old content\n"))
|
||||
const settled = yield* settleTool(
|
||||
const settled = yield* executeTool(
|
||||
registry,
|
||||
call(
|
||||
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch",
|
||||
),
|
||||
)
|
||||
expect(settled.output?.structured).toMatchObject({
|
||||
expect(settled.status).toBe("completed")
|
||||
if (settled.status !== "completed") return
|
||||
expect(settled.output).toMatchObject({
|
||||
applied: [{ type: "update", resource: "renamed/dir/name.txt" }],
|
||||
files: [
|
||||
{
|
||||
|
|
@ -393,7 +394,7 @@ describe("PatchTool", () => {
|
|||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "dir")))
|
||||
expect(
|
||||
yield* executeTool(registry, call("*** Begin Patch\n*** Delete File: dir\n*** End Patch")),
|
||||
).toMatchObject({ type: "error" })
|
||||
).toMatchObject({ status: "error" })
|
||||
expect(yield* exists(path.join(directory, "dir"))).toBe(true)
|
||||
}),
|
||||
),
|
||||
|
|
@ -407,11 +408,9 @@ describe("PatchTool", () => {
|
|||
expect(
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call(
|
||||
"*** Begin Patch\n*** Update File: two-chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch",
|
||||
),
|
||||
call("*** Begin Patch\n*** Update File: two-chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"),
|
||||
),
|
||||
).toMatchObject({ type: "error" })
|
||||
).toMatchObject({ status: "error" })
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("a\nb\nc\nd\n")
|
||||
}),
|
||||
),
|
||||
|
|
@ -420,7 +419,10 @@ describe("PatchTool", () => {
|
|||
it.live("requires patchText", () =>
|
||||
withTempTool((_directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* executeTool(registry, call(""))).toEqual({ type: "error", value: "patchText is required" })
|
||||
expect(yield* executeTool(registry, call(""))).toEqual({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: "patchText is required" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -429,12 +431,18 @@ describe("PatchTool", () => {
|
|||
withTempTool((_directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* executeTool(registry, call("invalid patch"))).toEqual({
|
||||
type: "error",
|
||||
value: "patch verification failed: The first line of the patch must be '*** Begin Patch'",
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: "patch verification failed: The first line of the patch must be '*** Begin Patch'",
|
||||
},
|
||||
})
|
||||
expect(yield* executeTool(registry, call("*** Begin Patch\n*** Add File: foo\n+hello"))).toEqual({
|
||||
type: "error",
|
||||
value: "patch verification failed: The last line of the patch must be '*** End Patch'",
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: "patch verification failed: The last line of the patch must be '*** End Patch'",
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
|
@ -444,8 +452,8 @@ describe("PatchTool", () => {
|
|||
withTempTool((_directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* executeTool(registry, call("*** Begin Patch\n*** End Patch"))).toEqual({
|
||||
type: "error",
|
||||
value: "patch rejected: empty patch",
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: "patch rejected: empty patch" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
|
@ -454,15 +462,13 @@ describe("PatchTool", () => {
|
|||
it.live("rejects an invalid hunk header", () =>
|
||||
withTempTool((_directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"),
|
||||
),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value:
|
||||
"patch verification failed: Invalid hunk at line 2: '*** Frobnicate File: foo' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'",
|
||||
expect(yield* executeTool(registry, call("*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"))).toEqual({
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message:
|
||||
"patch verification failed: Invalid hunk at line 2: '*** Frobnicate File: foo' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'",
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
|
@ -490,13 +496,13 @@ describe("PatchTool", () => {
|
|||
const bom = "\uFEFF"
|
||||
const target = path.join(directory, "example.cs")
|
||||
yield* Effect.promise(() => fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`))
|
||||
const settled = yield* settleTool(
|
||||
const settled = yield* executeTool(
|
||||
registry,
|
||||
call(
|
||||
"*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch",
|
||||
),
|
||||
call("*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"),
|
||||
)
|
||||
const output = Schema.decodeUnknownSync(PatchTool.Output)(settled.output?.structured)
|
||||
expect(settled.status).toBe("completed")
|
||||
if (settled.status !== "completed") return
|
||||
const output = Schema.decodeUnknownSync(PatchTool.Output)(settled.output)
|
||||
expect(output.files[0]?.patch).not.toContain(bom)
|
||||
expect(output.files[0]?.patch).not.toContain("-using System;")
|
||||
expect(output.files[0]?.patch).not.toContain("+using System;")
|
||||
|
|
@ -517,7 +523,10 @@ describe("PatchTool", () => {
|
|||
registry,
|
||||
call("*** Begin Patch\n*** Update File: unchanged.txt\n@@\n-missing\n+changed\n*** End Patch"),
|
||||
),
|
||||
).toMatchObject({ type: "error", value: expect.stringContaining("Failed to find expected lines") })
|
||||
).toMatchObject({
|
||||
status: "error",
|
||||
error: { message: expect.stringContaining("Failed to find expected lines") },
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("line1\nline2\n")
|
||||
}),
|
||||
),
|
||||
|
|
@ -532,10 +541,12 @@ describe("PatchTool", () => {
|
|||
call("*** Begin Patch\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"),
|
||||
),
|
||||
).toMatchObject({
|
||||
type: "error",
|
||||
value: expect.stringContaining(
|
||||
`patch verification failed: Failed to read file to update ${path.join(directory, "missing.txt")}: `,
|
||||
),
|
||||
status: "error",
|
||||
error: {
|
||||
message: expect.stringContaining(
|
||||
`patch verification failed: Failed to read file to update ${path.join(directory, "missing.txt")}: `,
|
||||
),
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
|
@ -548,8 +559,11 @@ describe("PatchTool", () => {
|
|||
expect(
|
||||
yield* executeTool(registry, call("*** Begin Patch\n*** Update File: nested\n@@\n-old\n+new\n*** End Patch")),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value: `patch verification failed: Failed to read file to update ${path.join(directory, "nested")}: path is a directory`,
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: `patch verification failed: Failed to read file to update ${path.join(directory, "nested")}: path is a directory`,
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
|
@ -560,7 +574,7 @@ describe("PatchTool", () => {
|
|||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* executeTool(registry, call("*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch")),
|
||||
).toMatchObject({ type: "error", value: expect.stringContaining("patch verification failed") })
|
||||
).toMatchObject({ status: "error", error: { message: expect.stringContaining("patch verification failed") } })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -580,7 +594,7 @@ describe("PatchTool", () => {
|
|||
registry,
|
||||
call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`),
|
||||
),
|
||||
).toMatchObject({ type: "text" })
|
||||
).toMatchObject({ status: "completed" })
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
||||
expect(readsBeforeEditApproval).toBe(1)
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
|
||||
|
|
@ -614,7 +628,7 @@ describe("PatchTool", () => {
|
|||
registry,
|
||||
call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`),
|
||||
),
|
||||
).toMatchObject({ type: "error" })
|
||||
).toMatchObject({ status: "error" })
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory"])
|
||||
expect(readsBeforeEditApproval).toBe(0)
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before\n")
|
||||
|
|
@ -649,7 +663,7 @@ describe("PatchTool", () => {
|
|||
registry,
|
||||
call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"),
|
||||
),
|
||||
).toMatchObject({ type: "text" })
|
||||
).toMatchObject({ status: "completed" })
|
||||
expect(assertions.map((input) => input.action)).toEqual(["edit"])
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
|
||||
}),
|
||||
|
|
@ -680,7 +694,7 @@ describe("PatchTool", () => {
|
|||
registry,
|
||||
call("*** Begin Patch\n*** Update File: link.txt\n@@\n-before\n+after\n*** End Patch"),
|
||||
),
|
||||
).toMatchObject({ type: "text" })
|
||||
).toMatchObject({ status: "completed" })
|
||||
expect(assertions.map((input) => input.action)).toEqual(["edit"])
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
|
||||
}),
|
||||
|
|
@ -711,7 +725,7 @@ describe("PatchTool", () => {
|
|||
registry,
|
||||
call(`*** Begin Patch\n*** Update File: ${relative}\n@@\n-before\n+after\n*** End Patch`),
|
||||
),
|
||||
).toMatchObject({ type: "text" })
|
||||
).toMatchObject({ status: "completed" })
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
||||
expect(readsBeforeEditApproval).toBe(1)
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
|
||||
|
|
@ -747,7 +761,7 @@ describe("PatchTool", () => {
|
|||
`*** Begin Patch\n*** Update File: ${first}\n@@\n-before\n+after\n*** Update File: ${second}\n@@\n-before\n+after\n*** End Patch`,
|
||||
),
|
||||
),
|
||||
).toMatchObject({ type: "text" })
|
||||
).toMatchObject({ status: "completed" })
|
||||
expect(assertions.map((input) => input.action)).toEqual([
|
||||
"external_directory",
|
||||
"external_directory",
|
||||
|
|
@ -786,8 +800,10 @@ describe("PatchTool", () => {
|
|||
),
|
||||
),
|
||||
).toMatchObject({
|
||||
type: "error",
|
||||
value: expect.stringContaining("patch verification failed: Failed to read file to update"),
|
||||
status: "error",
|
||||
error: {
|
||||
message: expect.stringContaining("patch verification failed: Failed to read file to update"),
|
||||
},
|
||||
})
|
||||
expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
|
||||
}),
|
||||
|
|
@ -812,7 +828,7 @@ describe("PatchTool", () => {
|
|||
registry,
|
||||
call("*** Begin Patch\n*** Add File: existing.txt\n+replacement\n*** End Patch"),
|
||||
),
|
||||
).toMatchObject({ type: "text" })
|
||||
).toMatchObject({ status: "completed" })
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("replacement\n")
|
||||
}),
|
||||
),
|
||||
|
|
@ -837,7 +853,7 @@ describe("PatchTool", () => {
|
|||
registry,
|
||||
call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch"),
|
||||
),
|
||||
).toMatchObject({ type: "text" })
|
||||
).toMatchObject({ status: "completed" })
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("replacement\n")
|
||||
}),
|
||||
)
|
||||
|
|
@ -876,5 +892,4 @@ describe("PatchTool", () => {
|
|||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { Image } from "@opencode-ai/core/image"
|
|||
import { testEffect } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_question_tool_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
|
|
@ -99,13 +99,13 @@ describe("QuestionTool", () => {
|
|||
|
||||
expect(yield* toolDefinitions(registry, [{ action: "question", resource: "*", effect: "deny" }])).toEqual([])
|
||||
expect(
|
||||
yield* settleTool(registry, {
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-question-denied", name: "question", input: questionInput },
|
||||
}),
|
||||
).toEqual({
|
||||
result: { type: "error", value: "Permission denied: question" },
|
||||
status: "error",
|
||||
error: {
|
||||
type: "permission.rejected",
|
||||
message: "Permission denied: question",
|
||||
|
|
@ -144,26 +144,21 @@ describe("QuestionTool", () => {
|
|||
|
||||
expect((yield* toolDefinitions(registry)).map((definition) => definition.name)).toEqual(["question"])
|
||||
expect(
|
||||
yield* settleTool(registry, {
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-question", name: "question", input: { questions } },
|
||||
}),
|
||||
).toEqual({
|
||||
result: {
|
||||
type: "text",
|
||||
value:
|
||||
'User has answered your questions: "What should happen?"="Build", "Which environment?"="Dev", "Anything else?"="Unanswered". You can now continue with the user\'s answers in mind.',
|
||||
},
|
||||
output: {
|
||||
structured: { answers: [["Build"], ["Dev"], []] },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Dev", "Anything else?"="Unanswered". You can now continue with the user\'s answers in mind.',
|
||||
},
|
||||
],
|
||||
},
|
||||
status: "completed",
|
||||
output: { answers: [["Build"], ["Dev"], []] },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Dev", "Anything else?"="Unanswered". You can now continue with the user\'s answers in mind.',
|
||||
},
|
||||
],
|
||||
metadata: { answers: [["Build"], ["Dev"], []] },
|
||||
})
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
|
||||
expect(capturedInput()).toEqual({
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
|
|||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const readToolNode = makeLocationNode({
|
||||
name: "test/read-tool-plugin",
|
||||
|
|
@ -199,21 +199,19 @@ describe("ReadTool", () => {
|
|||
|
||||
expect(yield* toolDefinitions(registry)).toMatchObject([{ name: "read" }])
|
||||
expect(yield* toolDefinitions(registry, [{ action: "read", resource: "*", effect: "deny" }])).toEqual([])
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
|
||||
}),
|
||||
).toEqual({
|
||||
type: "json",
|
||||
value: {
|
||||
uri: "file:///README.md",
|
||||
name: "README.md",
|
||||
content: "hello",
|
||||
encoding: "utf8",
|
||||
mime: "text/plain",
|
||||
},
|
||||
const execution = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
|
||||
})
|
||||
expect(execution.status).toBe("completed")
|
||||
if (execution.status !== "completed") return
|
||||
expect(execution.output).toEqual({
|
||||
uri: "file:///README.md",
|
||||
name: "README.md",
|
||||
content: "hello",
|
||||
encoding: "utf8",
|
||||
mime: "text/plain",
|
||||
})
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }])
|
||||
expect(readCalls).toEqual([
|
||||
|
|
@ -236,7 +234,7 @@ describe("ReadTool", () => {
|
|||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-external-read", name: "read", input: { path: external } },
|
||||
}),
|
||||
).toMatchObject({ type: "json" })
|
||||
).toMatchObject({ status: "completed" })
|
||||
expect(assertions).toMatchObject([
|
||||
{
|
||||
sessionID,
|
||||
|
|
@ -261,19 +259,17 @@ describe("ReadTool", () => {
|
|||
}
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-image", name: "read", input: { path: "pixel.png" } },
|
||||
}),
|
||||
).toEqual({
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "pixel.png" },
|
||||
],
|
||||
const execution = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-image", name: "read", input: { path: "pixel.png" } },
|
||||
})
|
||||
expect(execution.status).toBe("completed")
|
||||
if (execution.status !== "completed") return
|
||||
expect(execution.content).toEqual([
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "pixel.png" },
|
||||
])
|
||||
expect(readCalls).toEqual([
|
||||
{
|
||||
input: AbsolutePath.make(path.join(process.cwd(), "pixel.png")),
|
||||
|
|
@ -281,21 +277,17 @@ describe("ReadTool", () => {
|
|||
},
|
||||
])
|
||||
|
||||
const settled = yield* settleTool(registry, {
|
||||
const settled = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } },
|
||||
})
|
||||
expect(settled.output?.structured).toMatchObject({
|
||||
uri: "file:///pixel.png",
|
||||
name: "pixel.png",
|
||||
mime: "image/png",
|
||||
encoding: "base64",
|
||||
// Image base64 is carried by the content file item only; structured is slimmed
|
||||
// so the original bytes are never persisted twice.
|
||||
content: "",
|
||||
})
|
||||
expect(settled.output?.content).toMatchObject([
|
||||
expect(settled.status).toBe("completed")
|
||||
if (settled.status !== "completed") return
|
||||
// Image base64 is carried by the content file item only; read produces no
|
||||
// metadata, so the original bytes are never persisted twice.
|
||||
expect(settled.metadata).toBeUndefined()
|
||||
expect(settled.content).toMatchObject([
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` },
|
||||
])
|
||||
|
|
@ -319,26 +311,25 @@ describe("ReadTool", () => {
|
|||
}
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
const settled = yield* settleTool(registry, {
|
||||
const settled = yield* executeTool(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({
|
||||
expect(settled.status).toBe("completed")
|
||||
if (settled.status !== "completed") return
|
||||
expect(settled.output).toMatchObject({
|
||||
uri: "file:///large.png",
|
||||
name: "large.png",
|
||||
mime: "image/png",
|
||||
encoding: "base64",
|
||||
})
|
||||
expect(settled.result).toEqual({
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "large.png" },
|
||||
],
|
||||
})
|
||||
expect(settled.content).toEqual([
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "large.png" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -361,13 +352,13 @@ describe("ReadTool", () => {
|
|||
call: { type: "tool-call", id: "call-image-fallback", name: "read", input: { path: "pixel.png" } },
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: "content",
|
||||
value: [{ type: "text" }, { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png" }],
|
||||
status: "completed",
|
||||
content: [{ type: "text" }, { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("drops undecodable image data at settlement", () =>
|
||||
it.effect("drops undecodable image data from the outcome", () =>
|
||||
Effect.gen(function* () {
|
||||
readResult = {
|
||||
uri: "file:///truncated.png",
|
||||
|
|
@ -384,9 +375,9 @@ describe("ReadTool", () => {
|
|||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-truncated-image", name: "read", input: { path: "truncated.png" } },
|
||||
}),
|
||||
).toEqual({
|
||||
type: "content",
|
||||
value: [
|
||||
).toMatchObject({
|
||||
status: "completed",
|
||||
content: [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "text", text: "[1 image omitted: could not be decoded.]" },
|
||||
],
|
||||
|
|
@ -394,7 +385,7 @@ describe("ReadTool", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("drops oversized images at settlement when resizing is disabled", () =>
|
||||
it.effect("drops oversized images from the outcome when resizing is disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
|
||||
const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 16 * 4 }, () => 255)), 16, 1)
|
||||
|
|
@ -425,9 +416,9 @@ describe("ReadTool", () => {
|
|||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-wide-image", name: "read", input: { path: "wide.png" } },
|
||||
}),
|
||||
).toEqual({
|
||||
type: "content",
|
||||
value: [
|
||||
).toMatchObject({
|
||||
status: "completed",
|
||||
content: [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
|
||||
],
|
||||
|
|
@ -463,9 +454,9 @@ describe("ReadTool", () => {
|
|||
call: { type: "tool-call", id: "call-resize-image", name: "read", input: { path: "wide.png" } },
|
||||
})
|
||||
|
||||
expect(result.type).toBe("content")
|
||||
if (result.type !== "content") return
|
||||
const media = result.value[1]
|
||||
expect(result.status).toBe("completed")
|
||||
if (result.status !== "completed") return
|
||||
const media = result.content[1]
|
||||
expect(media?.type).toBe("file")
|
||||
if (media?.type !== "file") return
|
||||
const resized = photon.PhotonImage.new_from_byteslice(Buffer.from(media.uri.split(",")[1] ?? "", "base64"))
|
||||
|
|
@ -503,9 +494,9 @@ describe("ReadTool", () => {
|
|||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-max-bytes", name: "read", input: { path: "pixel.png" } },
|
||||
}),
|
||||
).toEqual({
|
||||
type: "content",
|
||||
value: [
|
||||
).toMatchObject({
|
||||
status: "completed",
|
||||
content: [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
|
||||
],
|
||||
|
|
@ -532,8 +523,8 @@ describe("ReadTool", () => {
|
|||
call: { type: "tool-call", id: "call-disguised-image", name: "read", input: { path: "pixel.bin" } },
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: "content",
|
||||
value: [{ type: "text" }, { type: "file", mime: "image/png", name: "pixel.bin" }],
|
||||
status: "completed",
|
||||
content: [{ type: "text" }, { type: "file", mime: "image/png", name: "pixel.bin" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
@ -554,7 +545,7 @@ describe("ReadTool", () => {
|
|||
input: { path: "archive.dat", offset: 2, limit: 1 },
|
||||
},
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" })
|
||||
).toEqual({ status: "error", error: { type: "unknown", message: "Cannot read binary file: archive.dat" } })
|
||||
expect(readCalls).toEqual([
|
||||
{ input: AbsolutePath.make(path.join(process.cwd(), "archive.dat")), page: { offset: 2, limit: 1 } },
|
||||
])
|
||||
|
|
@ -589,7 +580,7 @@ describe("ReadTool", () => {
|
|||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Unable to read README.md" })
|
||||
).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: read" } })
|
||||
expect(readCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
|
@ -604,7 +595,9 @@ describe("ReadTool", () => {
|
|||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-missing-path", name: "read", input: { path: missingPath } },
|
||||
}),
|
||||
).toEqual({ type: "error", value: `Unable to read ${missingPath}` })
|
||||
// The message-less PathError cause must not erase the tool's curated
|
||||
// failure message; the canonical error is the sole authority.
|
||||
).toEqual({ status: "error", error: { type: "tool.execution", message: `Unable to read ${missingPath}` } })
|
||||
expect(assertions).toEqual([])
|
||||
expect(readCalls).toEqual([])
|
||||
}),
|
||||
|
|
@ -626,7 +619,7 @@ describe("ReadTool", () => {
|
|||
input: { path: "src", offset: 2, limit: 10 },
|
||||
},
|
||||
}),
|
||||
).toEqual({ type: "json", value: { entries: [], truncated: false } })
|
||||
).toMatchObject({ status: "completed", output: { entries: [], truncated: false } })
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
|
||||
expect(listCalls).toEqual([{ offset: 2, limit: 10 }])
|
||||
}),
|
||||
|
|
@ -644,7 +637,7 @@ describe("ReadTool", () => {
|
|||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Unable to read src" })
|
||||
).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: read" } })
|
||||
expect(listCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
|
@ -691,9 +684,9 @@ describe("ReadTool", () => {
|
|||
input: { path: "large.txt", offset: 2, limit: 1 },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
type: "json",
|
||||
value: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
|
||||
).toMatchObject({
|
||||
status: "completed",
|
||||
output: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
|
||||
})
|
||||
expect(readCalls).toEqual([
|
||||
{ input: AbsolutePath.make(path.join(process.cwd(), "large.txt")), page: { offset: 2, limit: 1 } },
|
||||
|
|
@ -718,7 +711,7 @@ describe("ReadTool", () => {
|
|||
...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" })
|
||||
).toEqual({ status: "error", error: { type: "unknown", message: "Cannot read binary file: late-binary" } })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
|||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { executeTool, registerToolPlugin, settleTool, toolIdentity } from "./lib/tool"
|
||||
import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
|
||||
|
||||
const globToolNode = makeLocationNode({
|
||||
name: "test/glob-tool-plugin",
|
||||
|
|
@ -83,15 +83,17 @@ describe("search tools", () => {
|
|||
)
|
||||
yield* withTools(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const glob = yield* settleTool(registry, call("glob", { pattern: "*" }))
|
||||
const grep = yield* settleTool(registry, call("grep", { pattern: "needle" }))
|
||||
const glob = yield* executeTool(registry, call("glob", { pattern: "*" }))
|
||||
const grep = yield* executeTool(registry, call("grep", { pattern: "needle" }))
|
||||
|
||||
expect(glob.output?.structured).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT })
|
||||
expect(grep.output?.structured).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT })
|
||||
expect(glob.output?.content).toEqual([{ type: "text", text: String(glob.result.value) }])
|
||||
expect(grep.output?.content).toEqual([{ type: "text", text: String(grep.result.value) }])
|
||||
expect(String(glob.result.value).split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT)
|
||||
expect(grep.result.value).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`)
|
||||
expect(glob.metadata).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT })
|
||||
expect(grep.metadata).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT })
|
||||
expect(glob.content).toHaveLength(1)
|
||||
expect(grep.content).toHaveLength(1)
|
||||
const globText = glob.content?.[0]?.type === "text" ? glob.content[0].text : ""
|
||||
const grepText = grep.content?.[0]?.type === "text" ? grep.content[0].text : ""
|
||||
expect(globText.split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT)
|
||||
expect(grepText).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
|
@ -110,7 +112,10 @@ describe("search tools", () => {
|
|||
registry,
|
||||
call(name, { path: "missing", pattern: name === "glob" ? "*" : "needle" }),
|
||||
)
|
||||
expect(result).toEqual({ type: "error", value: "Search path does not exist: missing" })
|
||||
expect(result).toEqual({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: "Search path does not exist: missing" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
|||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions, waitForTool } from "./lib/tool"
|
||||
import { toolIdentity, executeTool, toolDefinitions, waitForTool } from "./lib/tool"
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_shell_tool_test")
|
||||
const sessionModel = ModelV2.Ref.make({ id: ModelV2.ID.make("test"), providerID: ProviderV2.ID.make("test") })
|
||||
|
|
@ -204,17 +204,19 @@ describe("ShellTool", () => {
|
|||
const definitions = yield* toolDefinitions(registry)
|
||||
const shell = definitions.find((tool) => tool.name === "shell")
|
||||
expect(shell).toBeDefined()
|
||||
expect(shell?.outputSchema).not.toHaveProperty("properties.output")
|
||||
// Code Mode receives the declared output schema, including the command output text.
|
||||
expect(shell?.outputSchema).toHaveProperty("properties.output")
|
||||
expect(
|
||||
(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
|
||||
(tool) => tool.name,
|
||||
),
|
||||
).not.toContain("shell")
|
||||
|
||||
const settled = yield* settleTool(registry, call({ command: helloCommand }))
|
||||
expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: false })
|
||||
expect(settled.output?.content[0]).toEqual({ type: "text", text: "hello" })
|
||||
expect(settled.output?.content[1]).toMatchObject({
|
||||
const settled = yield* executeTool(registry, call({ command: helloCommand }))
|
||||
expect(settled.status).toBe("completed")
|
||||
expect(settled.metadata).toMatchObject({ exit: 0, truncated: false })
|
||||
expect(settled.content?.[0]).toEqual({ type: "text", text: "hello" })
|
||||
expect(settled.content?.[1]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining("Command exited with code 0."),
|
||||
})
|
||||
|
|
@ -233,11 +235,11 @@ describe("ShellTool", () => {
|
|||
reset()
|
||||
return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe(
|
||||
Effect.andThen(
|
||||
withSession(tmp.path, (registry) => settleTool(registry, call({ command: cwdCommand, workdir: "src" }))),
|
||||
withSession(tmp.path, (registry) => executeTool(registry, call({ command: cwdCommand, workdir: "src" }))),
|
||||
),
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() =>
|
||||
expect(settled.output?.content[0]).toMatchObject({
|
||||
expect(settled.content?.[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining(realpathSync(path.join(tmp.path, "src"))),
|
||||
}),
|
||||
|
|
@ -256,13 +258,13 @@ describe("ShellTool", () => {
|
|||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const stderr = yield* settleTool(registry, call({ command: stderrCommand }, "call-stderr"))
|
||||
expect(stderr.output?.structured).toMatchObject({ exit: 0, truncated: false })
|
||||
expect(stderr.output?.content[0]).toEqual({ type: "text", text: "stderr only" })
|
||||
const stderr = yield* executeTool(registry, call({ command: stderrCommand }, "call-stderr"))
|
||||
expect(stderr.metadata).toMatchObject({ exit: 0, truncated: false })
|
||||
expect(stderr.content?.[0]).toEqual({ type: "text", text: "stderr only" })
|
||||
|
||||
const mixed = yield* settleTool(registry, call({ command: mixedOutputCommand }, "call-mixed"))
|
||||
expect(mixed.output?.structured).toMatchObject({ exit: 0, truncated: false })
|
||||
const output = mixed.output?.content[0]?.type === "text" ? mixed.output.content[0].text : ""
|
||||
const mixed = yield* executeTool(registry, call({ command: mixedOutputCommand }, "call-mixed"))
|
||||
expect(mixed.metadata).toMatchObject({ exit: 0, truncated: false })
|
||||
const output = mixed.content?.[0]?.type === "text" ? mixed.content[0].text : ""
|
||||
expect(output).toContain("stdout")
|
||||
expect(output).toContain("stderr")
|
||||
}),
|
||||
|
|
@ -352,12 +354,12 @@ describe("ShellTool", () => {
|
|||
reset()
|
||||
denyAction = "external_directory"
|
||||
const target = path.join(outside.path, "secret.txt")
|
||||
return withSession(active.path, (registry) => settleTool(registry, call({ command: `cat ${target}` }))).pipe(
|
||||
return withSession(active.path, (registry) => executeTool(registry, call({ command: `cat ${target}` }))).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
expect(assertions.map((item) => item.action)).toEqual(["shell"])
|
||||
expect(settled.output?.structured).not.toHaveProperty("warnings")
|
||||
expect(settled.output?.content[1]).toMatchObject({
|
||||
expect(settled.metadata).not.toHaveProperty("warnings")
|
||||
expect(settled.content?.[1]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining("Warnings:"),
|
||||
})
|
||||
|
|
@ -378,13 +380,14 @@ describe("ShellTool", () => {
|
|||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
settleTool(registry, call({ command: bodyExitCommand }, "call-nonzero")),
|
||||
executeTool(registry, call({ command: bodyExitCommand }, "call-nonzero")),
|
||||
).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
expect(settled.output?.structured).toMatchObject({ exit: 7, truncated: false })
|
||||
expect(settled.output?.content[0]).toEqual({ type: "text", text: "body" })
|
||||
expect(settled.output?.content[1]).toMatchObject({
|
||||
expect(settled.status).toBe("completed")
|
||||
expect(settled.metadata).toMatchObject({ exit: 7, truncated: false })
|
||||
expect(settled.content?.[0]).toEqual({ type: "text", text: "body" })
|
||||
expect(settled.content?.[1]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining("Command exited with code 7"),
|
||||
})
|
||||
|
|
@ -403,12 +406,12 @@ describe("ShellTool", () => {
|
|||
reset()
|
||||
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
|
||||
return withSession(tmp.path, (registry) =>
|
||||
settleTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
|
||||
executeTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
|
||||
).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: true })
|
||||
expect(settled.output?.content[0]).toMatchObject({
|
||||
expect(settled.metadata).toMatchObject({ exit: 0, truncated: true })
|
||||
expect(settled.content?.[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining("output truncated; full output saved to:"),
|
||||
})
|
||||
|
|
@ -421,7 +424,7 @@ describe("ShellTool", () => {
|
|||
)
|
||||
|
||||
it.live(
|
||||
"reports bounded output progress for a running command",
|
||||
"reports the shell ID for a running command",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
|
@ -431,32 +434,21 @@ describe("ShellTool", () => {
|
|||
const releasePath = path.join(tmp.path, release)
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const observed = yield* Deferred.make<ToolRegistry.Progress>()
|
||||
yield* settleTool(registry, {
|
||||
const observed = yield* Deferred.make<string>()
|
||||
yield* executeTool(registry, {
|
||||
...call(
|
||||
{ command: progressOverflowCommand(ShellTool.MAX_CAPTURE_BYTES + 1024, release) },
|
||||
"call-progress",
|
||||
),
|
||||
progress: (update) =>
|
||||
Effect.gen(function* () {
|
||||
if (update.structured.truncated !== true) return
|
||||
const content = update.content[0]
|
||||
if (content?.type !== "text") return
|
||||
if (content.text.indexOf("\n\n[output truncated; full output saved to:") !== ShellTool.MAX_CAPTURE_BYTES)
|
||||
return
|
||||
yield* Deferred.succeed(observed, update)
|
||||
if (typeof update.shellID !== "string") return
|
||||
yield* Deferred.succeed(observed, update.shellID)
|
||||
yield* Effect.promise(() => fs.writeFile(releasePath, ""))
|
||||
}),
|
||||
})
|
||||
|
||||
const progress = yield* Deferred.await(observed)
|
||||
expect(progress.structured).toEqual({ truncated: true })
|
||||
const content = progress.content[0]
|
||||
expect(content?.type).toBe("text")
|
||||
if (content?.type !== "text") return
|
||||
expect(content.text.indexOf("\n\n[output truncated; full output saved to:")).toBe(
|
||||
ShellTool.MAX_CAPTURE_BYTES,
|
||||
)
|
||||
expect(yield* Deferred.await(observed)).toMatch(/^sh_/)
|
||||
}).pipe(Effect.ensuring(Effect.promise(() => fs.writeFile(releasePath, "")).pipe(Effect.ignore))),
|
||||
)
|
||||
},
|
||||
|
|
@ -466,7 +458,7 @@ describe("ShellTool", () => {
|
|||
)
|
||||
|
||||
it.live(
|
||||
"does not repeat unchanged shell progress",
|
||||
"does not repeat shell ID progress",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
|
@ -475,16 +467,12 @@ describe("ShellTool", () => {
|
|||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const updates: ToolRegistry.Progress[] = []
|
||||
yield* settleTool(registry, {
|
||||
yield* executeTool(registry, {
|
||||
...call({ command: steadyProgressCommand }, "call-steady-progress"),
|
||||
progress: (update) => Effect.sync(() => updates.push(update)),
|
||||
})
|
||||
expect(updates).toEqual([
|
||||
{
|
||||
structured: { truncated: false },
|
||||
content: [{ type: "text", text: "steady" }],
|
||||
},
|
||||
])
|
||||
expect(updates).toHaveLength(1)
|
||||
expect(updates[0]?.shellID).toMatch(/^sh_/)
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
|
@ -493,18 +481,18 @@ describe("ShellTool", () => {
|
|||
{ timeout: 10_000 },
|
||||
)
|
||||
|
||||
it.live("returns a useful timeout settlement", () =>
|
||||
it.live("returns a useful timeout outcome", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
settleTool(registry, call({ command: idleCommand, timeout: 50 })),
|
||||
executeTool(registry, call({ command: idleCommand, timeout: 50 })),
|
||||
).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
expect(settled.output?.structured).toMatchObject({ timeout: true, truncated: false })
|
||||
expect(settled.output?.content[1]).toMatchObject({
|
||||
expect(settled.metadata).toMatchObject({ timeout: true, truncated: false })
|
||||
expect(settled.content?.[1]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining("Command timed out"),
|
||||
})
|
||||
|
|
@ -529,10 +517,9 @@ describe("ShellTool", () => {
|
|||
Stream.runHead,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const settled = yield* settleTool(registry, call({ command: idleCommand, timeout: 50, background: true }))
|
||||
const structured = settled.output?.structured as Record<string, unknown> | undefined
|
||||
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
|
||||
expect(settled.output?.structured).toMatchObject({ truncated: false })
|
||||
const settled = yield* executeTool(registry, call({ command: idleCommand, timeout: 50, background: true }))
|
||||
const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
|
||||
expect(settled.metadata).toMatchObject({ truncated: false })
|
||||
expect(shellID).toStartWith("sh_")
|
||||
|
||||
const shell = yield* Shell.Service
|
||||
|
|
@ -562,22 +549,22 @@ describe("ShellTool", () => {
|
|||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const shell = yield* Shell.Service
|
||||
const timed = yield* settleTool(
|
||||
const timed = yield* executeTool(
|
||||
registry,
|
||||
call({ command: idleCommand, background: true }, "call-updated-timeout"),
|
||||
)
|
||||
const timedID = (timed.output?.structured as Record<string, unknown> | undefined)?.shellID
|
||||
const timedID = timed.metadata?.shellID
|
||||
expect(typeof timedID).toBe("string")
|
||||
if (typeof timedID !== "string") return
|
||||
const timedShellID = ShellSchema.ID.make(timedID)
|
||||
yield* shell.timeout(timedShellID, 50)
|
||||
expect((yield* shell.wait(timedShellID)).status).toBe("timeout")
|
||||
|
||||
const cleared = yield* settleTool(
|
||||
const cleared = yield* executeTool(
|
||||
registry,
|
||||
call({ command: idleCommand, timeout: 50, background: true }, "call-cleared-timeout"),
|
||||
)
|
||||
const clearedID = (cleared.output?.structured as Record<string, unknown> | undefined)?.shellID
|
||||
const clearedID = cleared.metadata?.shellID
|
||||
expect(typeof clearedID).toBe("string")
|
||||
if (typeof clearedID !== "string") return
|
||||
const clearedShellID = ShellSchema.ID.make(clearedID)
|
||||
|
|
@ -601,7 +588,7 @@ describe("ShellTool", () => {
|
|||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const waiting = yield* settleTool(
|
||||
const waiting = yield* executeTool(
|
||||
registry,
|
||||
call({ command: idleCommand, timeout: 50 }, "call-background-signal"),
|
||||
).pipe(Effect.forkIn(scope, { startImmediately: true }))
|
||||
|
|
@ -616,14 +603,13 @@ describe("ShellTool", () => {
|
|||
})
|
||||
expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }])
|
||||
const settled = yield* Fiber.join(waiting)
|
||||
const structured = settled.output?.structured as Record<string, unknown> | undefined
|
||||
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
|
||||
expect(settled.output?.structured).toMatchObject({ truncated: false })
|
||||
expect(settled.output?.content[0]).toEqual({
|
||||
const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
|
||||
expect(settled.metadata).toMatchObject({ truncated: false })
|
||||
expect(settled.content?.[0]).toEqual({
|
||||
type: "text",
|
||||
text: "The command was moved to the background.",
|
||||
})
|
||||
expect(settled.output?.content[1]).toMatchObject({
|
||||
expect(settled.content?.[1]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining("DO NOT sleep, poll"),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { it } from "./lib/effect"
|
|||
import { imagePassthrough } from "./lib/image"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const skillToolNode = makeLocationNode({
|
||||
name: "test/skill-tool-plugin",
|
||||
|
|
@ -108,23 +108,22 @@ describe("SkillTool", () => {
|
|||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-skill", name: "skill", input: { id: "effect" } },
|
||||
}),
|
||||
).toEqual({
|
||||
type: "text",
|
||||
value: SkillTool.toModelOutput(info, [reference]),
|
||||
).toMatchObject({
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: SkillTool.toModelOutput(info, [reference]) }],
|
||||
})
|
||||
expect(SkillTool.toModelOutput(info, [reference])).toContain(`Base directory for this skill: ${directory}`)
|
||||
expect(
|
||||
yield* settleTool(registry, {
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { id: "effect" } },
|
||||
}),
|
||||
).toEqual({
|
||||
result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) },
|
||||
output: {
|
||||
structured: { name: "Effect", directory },
|
||||
content: [{ type: "text", text: SkillTool.toModelOutput(info, [reference]) }],
|
||||
},
|
||||
status: "completed",
|
||||
output: { name: "Effect", directory, output: SkillTool.toModelOutput(info, [reference]) },
|
||||
content: [{ type: "text", text: SkillTool.toModelOutput(info, [reference]) }],
|
||||
metadata: { name: "Effect", directory },
|
||||
})
|
||||
expect(assertions).toMatchObject([
|
||||
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
|
||||
|
|
@ -136,7 +135,10 @@ describe("SkillTool", () => {
|
|||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { id: "missing" } },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Unable to load skill missing" })
|
||||
).toEqual({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: "Unable to load skill missing" },
|
||||
})
|
||||
deny = true
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
|
|
@ -144,7 +146,10 @@ describe("SkillTool", () => {
|
|||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { id: "effect" } },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Unable to load skill effect" })
|
||||
).toEqual({
|
||||
status: "error",
|
||||
error: { type: "permission.rejected", message: "Permission denied: skill" },
|
||||
})
|
||||
deny = false
|
||||
const flat = SkillV2.Info.make({
|
||||
id: SkillV2.ID.make("public"),
|
||||
|
|
@ -166,7 +171,10 @@ describe("SkillTool", () => {
|
|||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { id: "public" } },
|
||||
}),
|
||||
).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) })
|
||||
).toMatchObject({
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: SkillTool.toModelOutput(flat, []) }],
|
||||
})
|
||||
}).pipe(Effect.provide(skillToolLayer))
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
|||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { executeTool, settleTool, toolIdentity, waitForTool } from "./lib/tool"
|
||||
import { executeTool, toolIdentity, waitForTool } from "./lib/tool"
|
||||
|
||||
const childText = "child final response"
|
||||
const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID: ProviderV2.ID.make("test") })
|
||||
|
|
@ -148,7 +148,7 @@ describe("SubagentTool", () => {
|
|||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toContain(SubagentTool.name)
|
||||
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toContain(SubagentTool.name)
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
|
|
@ -160,7 +160,10 @@ describe("SubagentTool", () => {
|
|||
input: { agent: "primary", description: "primary", prompt: "should fail" },
|
||||
},
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Agent primary cannot run as a subagent" })
|
||||
).toEqual({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: "Agent primary cannot run as a subagent" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
|
@ -193,7 +196,13 @@ describe("SubagentTool", () => {
|
|||
input: { agent: "reviewer", description: "nested", prompt: "should fail" },
|
||||
},
|
||||
}),
|
||||
).toEqual({ type: "error", value: expect.stringContaining("Subagent depth limit reached (1)") })
|
||||
).toEqual({
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: expect.stringContaining("Subagent depth limit reached (1)"),
|
||||
},
|
||||
})
|
||||
expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(0)
|
||||
}),
|
||||
),
|
||||
|
|
@ -219,7 +228,7 @@ describe("SubagentTool", () => {
|
|||
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
|
||||
const settled = yield* settleTool(registry, {
|
||||
const settled = yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
|
|
@ -231,17 +240,15 @@ describe("SubagentTool", () => {
|
|||
})
|
||||
|
||||
expect(settled).toMatchObject({
|
||||
result: { type: "text", value: childText },
|
||||
output: {
|
||||
structured: { status: "completed" },
|
||||
content: [{ type: "text", text: childText }],
|
||||
},
|
||||
status: "completed",
|
||||
metadata: { status: "completed" },
|
||||
content: [{ type: "text", text: childText }],
|
||||
})
|
||||
expect(settled.output?.structured).toEqual({
|
||||
sessionID: outputSessionID(settled.output?.structured),
|
||||
expect(settled.metadata).toEqual({
|
||||
sessionID: outputSessionID(settled.metadata),
|
||||
status: "completed",
|
||||
})
|
||||
expect((yield* sessions.get(outputSessionID(settled.output?.structured))).parentID).toBe(parent.id)
|
||||
expect((yield* sessions.get(outputSessionID(settled.metadata))).parentID).toBe(parent.id)
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
|
@ -263,7 +270,7 @@ describe("SubagentTool", () => {
|
|||
yield* waitForTool(registry, SubagentTool.name)
|
||||
const progress: ToolRegistry.Progress[] = []
|
||||
|
||||
const settled = yield* settleTool(registry, {
|
||||
const settled = yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
progress: (update) => Effect.sync(() => progress.push(update)),
|
||||
|
|
@ -276,15 +283,13 @@ describe("SubagentTool", () => {
|
|||
})
|
||||
|
||||
expect(settled).toMatchObject({
|
||||
result: { type: "text", value: childText },
|
||||
output: {
|
||||
structured: { status: "completed" },
|
||||
content: [{ type: "text", text: childText }],
|
||||
},
|
||||
status: "completed",
|
||||
metadata: { status: "completed" },
|
||||
content: [{ type: "text", text: childText }],
|
||||
})
|
||||
const child = yield* sessions.get(outputSessionID(settled.output?.structured))
|
||||
expect(settled.output?.structured).toEqual({ sessionID: child.id, status: "completed" })
|
||||
expect(progress[0]?.structured).toEqual({ sessionID: child.id, status: "running" })
|
||||
const child = yield* sessions.get(outputSessionID(settled.metadata))
|
||||
expect(settled.metadata).toEqual({ sessionID: child.id, status: "completed" })
|
||||
expect(progress[0]?.metadata).toEqual({ sessionID: child.id, status: "running" })
|
||||
expect(child).toMatchObject({
|
||||
parentID: parent.id,
|
||||
location: parent.location,
|
||||
|
|
@ -295,7 +300,7 @@ describe("SubagentTool", () => {
|
|||
"You are a subagent spawned by another session.\nreview this",
|
||||
)
|
||||
|
||||
const fallback = yield* settleTool(registry, {
|
||||
const fallback = yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
|
|
@ -305,7 +310,7 @@ describe("SubagentTool", () => {
|
|||
input: { agent: "fallback", description: "fallback", prompt: "fallback" },
|
||||
},
|
||||
})
|
||||
const fallbackChild = yield* sessions.get(outputSessionID(fallback.output?.structured))
|
||||
const fallbackChild = yield* sessions.get(outputSessionID(fallback.metadata))
|
||||
expect(fallbackChild).toMatchObject({ parentID: parent.id, model: parentModel })
|
||||
}),
|
||||
),
|
||||
|
|
@ -338,7 +343,13 @@ describe("SubagentTool", () => {
|
|||
input: { agent: "reviewer", description: "fail review", prompt: "please fail" },
|
||||
},
|
||||
}),
|
||||
).toEqual({ type: "error", value: expect.stringContaining("No model is available for session") })
|
||||
).toEqual({
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: expect.stringContaining("No model is available for session"),
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
|
@ -366,7 +377,7 @@ describe("SubagentTool", () => {
|
|||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
const settled = yield* settleTool(registry, {
|
||||
const settled = yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
|
|
@ -376,13 +387,12 @@ describe("SubagentTool", () => {
|
|||
input: { agent: "reviewer", description: "background review", prompt: "review", background: true },
|
||||
},
|
||||
})
|
||||
const childID = outputSessionID(settled.output?.structured)
|
||||
expect(settled.output?.structured).toMatchObject({
|
||||
const childID = outputSessionID(settled.metadata)
|
||||
expect(settled.metadata).toMatchObject({
|
||||
status: "running",
|
||||
})
|
||||
expect(settled.output?.structured).toEqual({ sessionID: childID, status: "running" })
|
||||
expect(settled.result).toEqual({ type: "text", value: expect.stringContaining(`id: ${childID}`) })
|
||||
expect(settled.output?.content).toEqual([{ type: "text", text: expect.stringContaining(`id: ${childID}`) }])
|
||||
expect(settled.metadata).toEqual({ sessionID: childID, status: "running" })
|
||||
expect(settled.content).toEqual([{ type: "text", text: expect.stringContaining(`id: ${childID}`) }])
|
||||
|
||||
const admission = Array.from(yield* Fiber.join(admitted))[0]
|
||||
expect(admission?.data.input.data.text).toContain(`<subagent id="${childID}" state="completed"`)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
|||
import { Image } from "@opencode-ai/core/image"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const webFetchToolNode = makeLocationNode({
|
||||
name: "test/webfetch-tool-plugin",
|
||||
|
|
@ -93,12 +93,11 @@ describe("WebFetchTool registration", () => {
|
|||
const url = "http://example.com/public"
|
||||
|
||||
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: { contentType: "text/plain" },
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
expect(yield* executeTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
|
||||
status: "completed",
|
||||
output: { url, contentType: "text/plain", format: "text", output: "hello" },
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
metadata: { contentType: "text/plain" },
|
||||
})
|
||||
expect(assertions).toMatchObject([
|
||||
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } },
|
||||
|
|
@ -113,9 +112,9 @@ describe("WebFetchTool registration", () => {
|
|||
const registry = yield* ToolRegistry.Service
|
||||
const url = "http://localhost/private"
|
||||
|
||||
expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({
|
||||
type: "text",
|
||||
value: "hello",
|
||||
expect(yield* executeTool(registry, call({ url, format: "text" }))).toMatchObject({
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
expect(assertions).toMatchObject([
|
||||
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
|
||||
|
|
@ -141,9 +140,9 @@ describe("WebFetchTool registration", () => {
|
|||
const registry = yield* ToolRegistry.Service
|
||||
const url = new URL("/redirect", server.url).toString()
|
||||
|
||||
expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({
|
||||
type: "text",
|
||||
value: "redirected",
|
||||
expect(yield* executeTool(registry, call({ url, format: "text" }))).toMatchObject({
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: "redirected" }],
|
||||
})
|
||||
expect(assertions).toMatchObject([
|
||||
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
|
||||
|
|
@ -158,9 +157,10 @@ describe("WebFetchTool registration", () => {
|
|||
reset()
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
// toSessionError unwraps the "Unable to fetch <url>" ToolFailure to its cause message.
|
||||
expect(yield* executeTool(registry, call({ url: "file:///etc/passwd", format: "text" }))).toEqual({
|
||||
type: "error",
|
||||
value: "Unable to fetch file:///etc/passwd",
|
||||
status: "error",
|
||||
error: { type: "unknown", message: "URL must use http:// or https://" },
|
||||
})
|
||||
expect(assertions).toEqual([])
|
||||
expect(requests).toEqual([])
|
||||
|
|
@ -178,13 +178,13 @@ describe("WebFetchTool registration", () => {
|
|||
)
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "markdown" }))).toEqual({
|
||||
type: "text",
|
||||
value: "# Hello\n\nworld",
|
||||
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "markdown" }))).toMatchObject({
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: "# Hello\n\nworld" }],
|
||||
})
|
||||
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
|
||||
type: "text",
|
||||
value: "Helloworld",
|
||||
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toMatchObject({
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: "Helloworld" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
@ -201,9 +201,9 @@ describe("WebFetchTool registration", () => {
|
|||
const registry = yield* ToolRegistry.Service
|
||||
const url = "https://1.1.1.1/deep-html"
|
||||
|
||||
expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toEqual({
|
||||
type: "error",
|
||||
value: `Unable to fetch ${url}`,
|
||||
expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toMatchObject({
|
||||
status: "error",
|
||||
error: { type: "unknown" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
@ -219,8 +219,11 @@ describe("WebFetchTool registration", () => {
|
|||
}),
|
||||
)
|
||||
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",
|
||||
status: "error",
|
||||
error: {
|
||||
type: "unknown",
|
||||
message: `Response too large (exceeds ${WebFetchTool.MAX_RESPONSE_BYTES} byte limit)`,
|
||||
},
|
||||
})
|
||||
|
||||
respond = () =>
|
||||
|
|
@ -228,26 +231,29 @@ describe("WebFetchTool registration", () => {
|
|||
new Response("x".repeat(WebFetchTool.MAX_RESPONSE_BYTES + 1), { headers: { "content-type": "text/plain" } }),
|
||||
)
|
||||
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",
|
||||
status: "error",
|
||||
error: {
|
||||
type: "unknown",
|
||||
message: `Response too large (exceeds ${WebFetchTool.MAX_RESPONSE_BYTES} byte limit)`,
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps images and files unsupported until typed settlement can carry attachments", () =>
|
||||
it.effect("keeps images and files unsupported until typed outcomes can carry attachments", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
const registry = yield* ToolRegistry.Service
|
||||
respond = () => Effect.succeed(new Response("png", { headers: { "content-type": "image/png" } }))
|
||||
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",
|
||||
status: "error",
|
||||
error: { type: "unknown", message: "Unsupported fetched image content type: image/png" },
|
||||
})
|
||||
|
||||
respond = () => Effect.succeed(new Response("pdf", { headers: { "content-type": "application/pdf" } }))
|
||||
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",
|
||||
status: "error",
|
||||
error: { type: "unknown", message: "Unsupported fetched file content type: application/pdf" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
@ -264,9 +270,9 @@ describe("WebFetchTool registration", () => {
|
|||
)
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
|
||||
type: "text",
|
||||
value: "ok",
|
||||
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toMatchObject({
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
})
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[0]?.headers["user-agent"]).toContain("Mozilla/5.0")
|
||||
|
|
@ -285,7 +291,10 @@ describe("WebFetchTool registration", () => {
|
|||
).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" })
|
||||
expect(yield* Fiber.join(fiber)).toEqual({
|
||||
status: "error",
|
||||
error: { type: "unknown", message: "Request timed out" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
|||
import { Image } from "@opencode-ai/core/image"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const webSearchToolNode = makeLocationNode({
|
||||
name: "test/websearch-tool-plugin",
|
||||
|
|
@ -172,7 +172,10 @@ describe("WebSearchTool registration", () => {
|
|||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({ type: "text", value: "exa results" })
|
||||
).toMatchObject({
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: "exa results" }],
|
||||
})
|
||||
expect(assertions).toMatchObject([
|
||||
{
|
||||
sessionID,
|
||||
|
|
@ -221,7 +224,7 @@ describe("WebSearchTool registration", () => {
|
|||
config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" }
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
const settled = yield* settleTool(registry, {
|
||||
const settled = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
|
||||
|
|
@ -242,11 +245,10 @@ describe("WebSearchTool registration", () => {
|
|||
})
|
||||
expect(requests[0]?.body).not.toHaveProperty("params.arguments.model_name")
|
||||
expect(settled).toEqual({
|
||||
result: { type: "text", value: "parallel results" },
|
||||
output: {
|
||||
structured: { provider: "parallel" },
|
||||
content: [{ type: "text", text: "parallel results" }],
|
||||
},
|
||||
status: "completed",
|
||||
output: { provider: "parallel", text: "parallel results" },
|
||||
content: [{ type: "text", text: "parallel results" }],
|
||||
metadata: { provider: "parallel" },
|
||||
})
|
||||
expect(JSON.stringify(settled)).not.toContain("parallel-secret")
|
||||
}),
|
||||
|
|
@ -260,7 +262,7 @@ describe("WebSearchTool registration", () => {
|
|||
config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" }
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
const settled = yield* settleTool(registry, {
|
||||
const settled = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } },
|
||||
|
|
@ -285,7 +287,10 @@ describe("WebSearchTool registration", () => {
|
|||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-empty", name: "websearch", input: { query: "nothing" } },
|
||||
}),
|
||||
).toEqual({ type: "text", value: WebSearchTool.NO_RESULTS })
|
||||
).toMatchObject({
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: WebSearchTool.NO_RESULTS }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -318,7 +323,12 @@ describe("WebSearchTool registration", () => {
|
|||
...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" })
|
||||
// toSessionError unwraps the "Unable to search the web for <query>" ToolFailure
|
||||
// to its byte-limit cause message.
|
||||
).toEqual({
|
||||
status: "error",
|
||||
error: { type: "unknown", message: expect.stringContaining("response exceeded") },
|
||||
})
|
||||
expect(chunksRead).toBeLessThan(10)
|
||||
expect(cancelled).toBe(true)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { location } from "./fixture/location"
|
|||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const writeToolNode = makeLocationNode({
|
||||
name: "test/write-tool-plugin",
|
||||
|
|
@ -119,18 +119,16 @@ describe("WriteTool", () => {
|
|||
return withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write"])
|
||||
const settled = yield* settleTool(registry, call({ path: "src/new.txt", content: "created" }))
|
||||
const settled = yield* executeTool(registry, call({ path: "src/new.txt", content: "created" }))
|
||||
expect(settled).toEqual({
|
||||
result: { type: "text", value: "Created file successfully: src/new.txt" },
|
||||
status: "completed",
|
||||
output: {
|
||||
structured: {
|
||||
operation: "write",
|
||||
target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"),
|
||||
resource: "src/new.txt",
|
||||
existed: false,
|
||||
},
|
||||
content: [{ type: "text", text: "Created file successfully: src/new.txt" }],
|
||||
operation: "write",
|
||||
target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"),
|
||||
resource: "src/new.txt",
|
||||
existed: false,
|
||||
},
|
||||
content: [{ type: "text", text: "Created file successfully: src/new.txt" }],
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe(
|
||||
"created",
|
||||
|
|
@ -151,12 +149,14 @@ describe("WriteTool", () => {
|
|||
reset()
|
||||
return Effect.promise(() => fs.writeFile(path.join(tmp.path, "existing.txt"), "before")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) => settleTool(registry, call({ path: "existing.txt", content: "after" }))),
|
||||
withTool(tmp.path, (registry) => executeTool(registry, call({ path: "existing.txt", content: "after" }))),
|
||||
),
|
||||
Effect.andThen((settled) =>
|
||||
Effect.gen(function* () {
|
||||
expect(settled.result).toEqual({ type: "text", value: "Wrote file successfully: existing.txt" })
|
||||
expect(settled.output?.structured).toMatchObject({ resource: "existing.txt", existed: true })
|
||||
expect(settled.status).toBe("completed")
|
||||
if (settled.status !== "completed") return
|
||||
expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }])
|
||||
expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true })
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe(
|
||||
"after",
|
||||
)
|
||||
|
|
@ -182,8 +182,8 @@ describe("WriteTool", () => {
|
|||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
yield* settleTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved"))
|
||||
yield* settleTool(
|
||||
yield* executeTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved"))
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"),
|
||||
)
|
||||
|
|
@ -208,7 +208,10 @@ describe("WriteTool", () => {
|
|||
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" })
|
||||
expect(result).toMatchObject({
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: "Created file successfully: absolute.txt" }],
|
||||
})
|
||||
expect(assertions.map((input) => input.action)).toEqual(["edit"])
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("inside")
|
||||
}),
|
||||
|
|
@ -236,7 +239,7 @@ describe("WriteTool", () => {
|
|||
),
|
||||
Effect.andThen((result) =>
|
||||
Effect.sync(() => {
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.status).toBe("completed")
|
||||
expect(assertions.map((input) => input.action)).toEqual(["edit"])
|
||||
expect(assertions[0]?.resources).toEqual(["link.txt"])
|
||||
}),
|
||||
|
|
@ -259,7 +262,7 @@ describe("WriteTool", () => {
|
|||
reset()
|
||||
const target = path.join(outside.path, "external.txt")
|
||||
return withTool(active.path, (registry) =>
|
||||
settleTool(registry, call({ path: target, content: "external" })),
|
||||
executeTool(registry, call({ path: target, content: "external" })),
|
||||
).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -271,10 +274,13 @@ describe("WriteTool", () => {
|
|||
],
|
||||
})
|
||||
expect(assertions[1]).toMatchObject({ resources: [canonicalTarget.replaceAll("\\", "/")], save: ["*"] })
|
||||
expect(settled.output?.structured).toMatchObject({
|
||||
target: canonicalTarget,
|
||||
resource: canonicalTarget.replaceAll("\\", "/"),
|
||||
existed: false,
|
||||
expect(settled).toMatchObject({
|
||||
status: "completed",
|
||||
output: {
|
||||
target: canonicalTarget,
|
||||
resource: canonicalTarget.replaceAll("\\", "/"),
|
||||
existed: false,
|
||||
},
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("external")
|
||||
expect(writes).toEqual([canonicalTarget])
|
||||
|
|
@ -336,8 +342,8 @@ describe("WriteTool", () => {
|
|||
executeTool(registry, call({ path: external, content: "blocked" })),
|
||||
),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value: `Unable to write ${external}`,
|
||||
status: "error",
|
||||
error: { type: "permission.rejected", message: "Permission denied: external_directory" },
|
||||
})
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory"])
|
||||
expect(writes).toEqual([])
|
||||
|
|
@ -349,8 +355,8 @@ describe("WriteTool", () => {
|
|||
executeTool(registry, call({ path: "denied.txt", content: "blocked" })),
|
||||
),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value: "Unable to write denied.txt",
|
||||
status: "error",
|
||||
error: { type: "permission.rejected", message: "Permission denied: edit" },
|
||||
})
|
||||
expect(assertions.map((input) => input.action)).toEqual(["edit"])
|
||||
expect(writes).toEqual([])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue