chore: merge v2
This commit is contained in:
commit
3dffa01054
164 changed files with 5994 additions and 2957 deletions
|
|
@ -77,7 +77,7 @@
|
|||
"@ai-sdk/google": "3.0.73",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/mistral": "3.0.51",
|
||||
"@ai-sdk/openai": "3.0.84",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
|
|
@ -91,7 +91,7 @@
|
|||
"@effect/sql-sqlite-bun": "catalog:",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@ff-labs/fff-bun": "0.9.4",
|
||||
"@ff-labs/fff-bun": "0.10.1",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -422,7 +422,6 @@ function callOptions(request: LLMRequest): LanguageModelV3CallOptions {
|
|||
presencePenalty: request.generation?.presencePenalty,
|
||||
frequencyPenalty: request.generation?.frequencyPenalty,
|
||||
seed: request.generation?.seed,
|
||||
responseFormat: responseFormat(request),
|
||||
tools: request.tools.map(tool),
|
||||
toolChoice: toolChoice(request.toolChoice),
|
||||
headers: request.http?.headers,
|
||||
|
|
@ -527,12 +526,6 @@ function toolChoice(input: LLMRequest["toolChoice"]): LanguageModelV3ToolChoice
|
|||
return { type: input.type }
|
||||
}
|
||||
|
||||
function responseFormat(request: LLMRequest): LanguageModelV3CallOptions["responseFormat"] {
|
||||
if (request.responseFormat?.type === "json")
|
||||
return { type: "json", schema: request.responseFormat.schema as JSONSchema7 }
|
||||
if (request.responseFormat) return { type: "text" }
|
||||
}
|
||||
|
||||
function providerOptions(input: LLMRequest["providerOptions"]): SharedV3ProviderOptions | undefined {
|
||||
if (!input) return undefined
|
||||
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)]))
|
||||
|
|
|
|||
|
|
@ -4,19 +4,17 @@ import { Context, Effect, Layer, Scope } from "effect"
|
|||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { PermissionV2 } from "./permission"
|
||||
import { ExecuteTool } from "./tool/execute"
|
||||
import { permission, registrationEntries, type AnyTool } from "./tool/tool"
|
||||
import { Tools } from "./tool/tools"
|
||||
import type { Any, Registration } from "./tool/tool"
|
||||
import { Wildcard } from "./util/wildcard"
|
||||
|
||||
export interface Materialization {
|
||||
readonly tool?: AnyTool
|
||||
readonly tool?: Any
|
||||
readonly instructions?: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (
|
||||
tools: Readonly<Record<string, AnyTool>>,
|
||||
options?: Tools.RegisterOptions,
|
||||
registrations: ReadonlyArray<Registration & { readonly key: string }>,
|
||||
) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
|
||||
}
|
||||
|
|
@ -26,29 +24,28 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const local = new Map<
|
||||
string,
|
||||
Array<{ readonly token: object; readonly registration: ExecuteTool.Registration }>
|
||||
>()
|
||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: ExecuteTool.Registration }>>()
|
||||
|
||||
return Service.of({
|
||||
register: Effect.fn("CodeMode.register")(function* (tools, options) {
|
||||
const entries = registrationEntries(tools, options?.namespace)
|
||||
if (entries.length === 0) return
|
||||
register: Effect.fn("CodeMode.register")(function* (registrations) {
|
||||
if (registrations.length === 0) return
|
||||
yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const token = {}
|
||||
for (const entry of entries)
|
||||
local.set(entry.key, [
|
||||
...(local.get(entry.key) ?? []),
|
||||
{ token, registration: { tool: entry.tool, name: entry.name, namespace: entry.namespace } },
|
||||
for (const registration of registrations)
|
||||
local.set(registration.key, [
|
||||
...(local.get(registration.key) ?? []),
|
||||
{
|
||||
token,
|
||||
registration,
|
||||
},
|
||||
])
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
for (const entry of entries) {
|
||||
const registrations = local.get(entry.key)?.filter((item) => item.token !== token) ?? []
|
||||
if (registrations.length > 0) local.set(entry.key, registrations)
|
||||
else local.delete(entry.key)
|
||||
for (const registration of registrations) {
|
||||
const remaining = local.get(registration.key)?.filter((item) => item.token !== token) ?? []
|
||||
if (remaining.length > 0) local.set(registration.key, remaining)
|
||||
else local.delete(registration.key)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
|
@ -61,7 +58,7 @@ const layer = Layer.effect(
|
|||
for (const [name, entries] of local) {
|
||||
const registration = entries.at(-1)?.registration
|
||||
if (!registration) continue
|
||||
const rule = rules.findLast((rule) => Wildcard.match(permission(registration.tool, name), rule.action))
|
||||
const rule = rules.findLast((rule) => Wildcard.match(registration.permission, rule.action))
|
||||
if (rule?.resource === "*" && rule.effect === "deny") continue
|
||||
registrations.set(name, registration)
|
||||
}
|
||||
|
|
|
|||
1
packages/core/src/database/migration.gen.ts
generated
1
packages/core/src/database/migration.gen.ts
generated
|
|
@ -56,5 +56,6 @@ export const migrations = (
|
|||
import("./migration/20260710025429_instruction_sync"),
|
||||
import("./migration/20260716020354_kv"),
|
||||
import("./migration/20260722011141_delete_tool_progress_events"),
|
||||
import("./migration/20260722170000_canonical_tool_results"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
import { sql } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
const isObject = Schema.is(Schema.Record(Schema.String, Schema.Unknown))
|
||||
const isJsonObject = Schema.is(Schema.Record(Schema.String, Schema.Json))
|
||||
|
||||
const object = (value: unknown): Record<string, unknown> => (isObject(value) ? value : {})
|
||||
|
||||
const stringify = (value: unknown) => {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2) ?? String(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
const contentOf = (state: Record<string, unknown>) => (Array.isArray(state.content) ? state.content : [])
|
||||
const resultOf = (state: Record<string, unknown>) =>
|
||||
isObject(state.result) && "value" in state.result ? state.result.value : state.result
|
||||
const metadataOf = (state: Record<string, unknown>) => {
|
||||
if (isJsonObject(state.structured) && Object.keys(state.structured).length > 0)
|
||||
return { metadata: state.structured }
|
||||
return isJsonObject(state.metadata) ? { metadata: state.metadata } : {}
|
||||
}
|
||||
const completedContent = (state: Record<string, unknown>) => {
|
||||
const preserved = contentOf(state)
|
||||
if (preserved.length > 0) return preserved
|
||||
return [{ type: "text", text: stringify(Object.keys(object(state.structured)).length ? state.structured : resultOf(state)) }]
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time rewrite of projected tool rows into the canonical result shape:
|
||||
* terminal states store model content plus optional metadata; the generic
|
||||
* `structured` and `result` fields disappear. Provider-hosted result payloads
|
||||
* move into provider-owned result state so hosted continuation survives.
|
||||
* Pre-release durable event versions are intentionally left untouched.
|
||||
*/
|
||||
export default {
|
||||
id: "20260722170000_canonical_tool_results",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
// Keyset-paginated batches keep memory bounded: production databases hold
|
||||
// gigabytes of assistant rows, and materializing them all at once was
|
||||
// measured at a ~5GB RSS spike.
|
||||
let cursor = ""
|
||||
while (true) {
|
||||
const messages = yield* tx.all<{ id: string; data: string }>(
|
||||
sql`SELECT id, data FROM session_message WHERE type = 'assistant' AND id > ${cursor} ORDER BY id LIMIT 1000`,
|
||||
)
|
||||
if (messages.length === 0) break
|
||||
cursor = messages[messages.length - 1].id
|
||||
yield* rewrite(tx, messages)
|
||||
}
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
||||
function rewrite(tx: Parameters<DatabaseMigration.Migration["up"]>[0], messages: { id: string; data: string }[]) {
|
||||
return Effect.gen(function* () {
|
||||
for (const row of messages) {
|
||||
// A row that never decoded is skipped rather than failing the whole
|
||||
// migration on every startup; it was equally unreadable before.
|
||||
const decoded = decodeJson(row.data)
|
||||
if (decoded._tag === "None") {
|
||||
yield* Effect.logWarning("skipping undecodable session_message row").pipe(Effect.annotateLogs({ id: row.id }))
|
||||
continue
|
||||
}
|
||||
const data = object(decoded.value)
|
||||
if (!Array.isArray(data.content)) continue
|
||||
let changed = false
|
||||
const content = data.content.map((part) => {
|
||||
const tool = object(part)
|
||||
if (tool.type !== "tool" || !isObject(tool.state)) return part
|
||||
const state = tool.state
|
||||
if (state.status !== "completed" && state.status !== "error" && state.status !== "running") return part
|
||||
if (!("structured" in state) && !("result" in state)) return part
|
||||
changed = true
|
||||
if (state.status === "running")
|
||||
return {
|
||||
...tool,
|
||||
state: {
|
||||
status: "running",
|
||||
input: object(state.input),
|
||||
metadata: object(state.structured),
|
||||
},
|
||||
}
|
||||
// Hosted payloads are irreducible provider replay state; keep them under
|
||||
// the provider-owned result state instead of a generic result field.
|
||||
const hosted =
|
||||
tool.executed === true && isObject(state.result) && "value" in state.result
|
||||
? { providerResultState: { ...object(tool.providerResultState), result: state.result.value } }
|
||||
: {}
|
||||
const preserved = contentOf(state)
|
||||
if (state.status === "completed")
|
||||
return {
|
||||
...tool,
|
||||
...hosted,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: object(state.input),
|
||||
content: completedContent(state),
|
||||
...metadataOf(state),
|
||||
},
|
||||
}
|
||||
return {
|
||||
...tool,
|
||||
...hosted,
|
||||
state: {
|
||||
status: "error",
|
||||
input: object(state.input),
|
||||
error: state.error,
|
||||
...(preserved.length > 0 ? { content: preserved } : {}),
|
||||
...metadataOf(state),
|
||||
},
|
||||
}
|
||||
})
|
||||
if (!changed) continue
|
||||
yield* tx.run(sql`UPDATE session_message SET data = ${JSON.stringify({ ...data, content })} WHERE id = ${row.id}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -127,8 +127,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
),
|
||||
},
|
||||
model: {
|
||||
get: (providerID, modelID) =>
|
||||
catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
|
||||
get: (providerID, modelID) => catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
|
||||
list: () => response(catalog.model.available()),
|
||||
default: () => response(catalog.model.default()),
|
||||
},
|
||||
|
|
@ -358,7 +357,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
Effect.gen(function* () {
|
||||
const registrations: Array<{
|
||||
readonly name: string
|
||||
readonly tool: Tool.AnyTool
|
||||
readonly tool: Tool.Any
|
||||
readonly options?: Tool.RegisterOptions
|
||||
}> = []
|
||||
yield* Effect.sync(() =>
|
||||
|
|
@ -395,6 +394,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
})
|
||||
}
|
||||
return toolHooks.hook.after((event) => {
|
||||
// Decode first so plugin mutations cannot alias the canonical outcome.
|
||||
const output = {
|
||||
tool: event.tool,
|
||||
sessionID: event.sessionID,
|
||||
|
|
@ -402,18 +402,30 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
messageID: event.messageID,
|
||||
callID: event.callID,
|
||||
input: event.input,
|
||||
result: event.result,
|
||||
output: event.output,
|
||||
outputPaths: event.outputPaths,
|
||||
...Schema.decodeUnknownSync(Tool.ExecuteAfterOutcome)(event),
|
||||
}
|
||||
return Reflect.apply(callback, undefined, [output]).pipe(
|
||||
Effect.tap(() =>
|
||||
Effect.sync(() => {
|
||||
event.result = output.result
|
||||
event.output = output.output
|
||||
event.outputPaths = output.outputPaths
|
||||
}),
|
||||
),
|
||||
Effect.tap(() => {
|
||||
const decoded = Schema.decodeUnknownOption(Tool.ExecuteAfterOutcome)(output)
|
||||
if (decoded._tag === "None")
|
||||
return Effect.logWarning("ignoring invalid execute.after tool outcome", { tool: event.tool })
|
||||
if (decoded.value.status !== event.status)
|
||||
return Effect.logWarning("ignoring execute.after tool status change", { tool: event.tool })
|
||||
return Effect.sync(() => {
|
||||
if (event.status === "completed" && decoded.value.status === "completed") {
|
||||
event.content = decoded.value.content
|
||||
event.metadata = decoded.value.metadata
|
||||
event.outputPaths = decoded.value.outputPaths
|
||||
return
|
||||
}
|
||||
if (event.status === "error" && decoded.value.status === "error") {
|
||||
event.error = decoded.value.error
|
||||
event.content = decoded.value.content
|
||||
event.metadata = decoded.value.metadata
|
||||
event.outputPaths = decoded.value.outputPaths
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ export * as PluginPromise from "./promise"
|
|||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import type { Context, Plugin } from "@opencode-ai/plugin/v2/plugin"
|
||||
import type { AnyTool } from "@opencode-ai/plugin/v2/tool"
|
||||
import type { Any, RegisterOptions } from "@opencode-ai/plugin/v2/tool"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
|
|
@ -189,7 +189,8 @@ export function fromPromise(plugin: Plugin) {
|
|||
register(
|
||||
host.tool.transform((draft) =>
|
||||
callback({
|
||||
add: (tool: AnyTool) => draft.add(tool.name, fromPromiseTool(tool), tool.options),
|
||||
add: (name: string, tool: Any, options?: RegisterOptions) =>
|
||||
draft.add(name, fromPromiseTool(tool), options),
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
|
@ -302,19 +303,8 @@ function wireEvent(value: unknown): unknown {
|
|||
return wire(value)
|
||||
}
|
||||
|
||||
function fromPromiseTool(tool: AnyTool) {
|
||||
if ("jsonSchema" in tool)
|
||||
return Tool.make({
|
||||
...tool,
|
||||
execute: (input, context) =>
|
||||
Effect.promise(() =>
|
||||
tool.execute(input, {
|
||||
...context,
|
||||
progress: (update) => Effect.runPromise(context.progress(update)),
|
||||
}),
|
||||
),
|
||||
})
|
||||
return Tool.make({
|
||||
function fromPromiseTool(tool: Any): Tool.Any {
|
||||
return {
|
||||
...tool,
|
||||
execute: (input, context) =>
|
||||
Effect.promise(() =>
|
||||
|
|
@ -323,5 +313,5 @@ function fromPromiseTool(tool: AnyTool) {
|
|||
progress: (update) => Effect.runPromise(context.progress(update)),
|
||||
}),
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,8 +36,8 @@ export const layer = Layer.effect(
|
|||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(selection.session.id)
|
||||
? selection.session.id.slice(4)
|
||||
: selection.session.id
|
||||
const executableTools = yield* registry.materialize(selection.agent.info.permissions)
|
||||
const toolDefinitions = executableTools.definitions
|
||||
const toolSet = yield* registry.snapshot(selection.agent.info.permissions)
|
||||
const toolDefinitions = toolSet.definitions
|
||||
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
|
||||
const contextEvent = yield* hooks.trigger("session", "context", {
|
||||
sessionID: selection.session.id,
|
||||
|
|
@ -52,7 +52,10 @@ export const layer = Layer.effect(
|
|||
Message.user(input.prompt),
|
||||
],
|
||||
tools: Object.fromEntries(
|
||||
toolDefinitions.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]),
|
||||
toolDefinitions.map((tool) => [
|
||||
tool.name,
|
||||
{ description: tool.description, input: { ...tool.inputSchema } },
|
||||
]),
|
||||
),
|
||||
})
|
||||
const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => {
|
||||
|
|
@ -74,7 +77,6 @@ export const layer = Layer.effect(
|
|||
system: contextEvent.system,
|
||||
messages: contextEvent.messages,
|
||||
tools: hookedTools,
|
||||
toolChoice: "none",
|
||||
}),
|
||||
)
|
||||
yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage })
|
||||
|
|
|
|||
|
|
@ -355,8 +355,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
SessionMessage.ToolStateRunning.make({
|
||||
status: "running",
|
||||
input: event.data.input,
|
||||
structured: {},
|
||||
content: [],
|
||||
metadata: {},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -366,11 +365,12 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && match.state.status === "running") {
|
||||
match.state.structured = event.data.structured
|
||||
match.state.content = [...event.data.content]
|
||||
match.state.metadata = event.data.metadata
|
||||
}
|
||||
})
|
||||
},
|
||||
// Terminal tool events are self-contained; projection is a direct copy and
|
||||
// never reaches into ephemeral progress history.
|
||||
"session.tool.success": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
|
|
@ -382,9 +382,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
SessionMessage.ToolStateCompleted.make({
|
||||
status: "completed",
|
||||
input: match.state.input,
|
||||
structured: event.data.structured,
|
||||
content: [...event.data.content],
|
||||
result: event.data.result,
|
||||
content: event.data.content,
|
||||
...(event.data.metadata === undefined ? {} : { metadata: event.data.metadata }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -402,9 +401,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
status: "error",
|
||||
error: event.data.error,
|
||||
input: typeof match.state.input === "string" ? {} : match.state.input,
|
||||
structured: event.data.metadata ?? (match.state.status === "running" ? match.state.structured : {}),
|
||||
content: event.data.content ?? (match.state.status === "running" ? match.state.content : []),
|
||||
result: event.data.result,
|
||||
...(event.data.content === undefined ? {} : { content: event.data.content }),
|
||||
...(event.data.metadata === undefined ? {} : { metadata: event.data.metadata }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,13 +14,15 @@ import { MAX_STEPS_PROMPT } from "./runner/max-steps"
|
|||
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
|
||||
import { toLLMMessages } from "./runner/to-llm-message"
|
||||
|
||||
type ToolCallResolution =
|
||||
| { readonly type: "reject"; readonly error: SessionError.Error }
|
||||
| { readonly type: "settle"; readonly settle: ToolRegistry.Materialization["settle"] }
|
||||
|
||||
interface Prepared {
|
||||
readonly request: LLMRequest
|
||||
readonly resolveToolCall: (name: string) => ToolCallResolution
|
||||
/**
|
||||
* One request-scoped execution operation. Unknown, hook-removed, and
|
||||
* step-limit-violating calls fail individually through the same seam.
|
||||
*/
|
||||
readonly executeTool: ToolRegistry.ToolSet["execute"]
|
||||
/** True when this request is the final Step; violating calls are rejected and no continuation follows. */
|
||||
readonly stepLimitReached: boolean
|
||||
}
|
||||
|
||||
interface PrepareInput {
|
||||
|
|
@ -94,14 +96,16 @@ export const layer = Layer.effect(
|
|||
const model = resolved.model
|
||||
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
|
||||
const stepLimitReached = agent.info.steps !== undefined && input.step >= agent.info.steps
|
||||
const executableTools = stepLimitReached ? undefined : yield* registry.materialize(agent.info.permissions)
|
||||
// The final Step keeps definitions available to protocols with native "none",
|
||||
// preserving their prompt cache prefix. Calls are still rejected at execution.
|
||||
const toolSet = yield* registry.snapshot(agent.info.permissions)
|
||||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
|
||||
const system = [agent.info.system ? agent.info.system : PROMPT_DEFAULT, input.context.initial]
|
||||
.filter((part) => part.length > 0)
|
||||
.map(SystemPart.make)
|
||||
const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey)
|
||||
const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history
|
||||
const toolDefinitions = executableTools?.definitions ?? []
|
||||
const toolDefinitions = toolSet.definitions
|
||||
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
|
||||
// Hooks may reshape available definitions but cannot advertise tools omitted by permissions or the Step limit.
|
||||
const contextEvent = yield* hooks.trigger("session", "context", {
|
||||
|
|
@ -131,22 +135,23 @@ export const layer = Layer.effect(
|
|||
tools: hookedTools,
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
})
|
||||
const resolveToolCall = (name: string): ToolCallResolution => {
|
||||
if (!executableTools)
|
||||
return {
|
||||
type: "reject",
|
||||
const executeTool: ToolRegistry.ToolSet["execute"] = (executeInput) => {
|
||||
if (stepLimitReached)
|
||||
return Effect.succeed({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: "Tools are disabled after the maximum agent steps" },
|
||||
}
|
||||
if (toolsByName.has(name) && !Object.hasOwn(contextEvent.tools, name))
|
||||
return {
|
||||
type: "reject",
|
||||
error: { type: "tool.execution", message: `Tool is not available for this request: ${name}` },
|
||||
}
|
||||
return { type: "settle", settle: executableTools.settle }
|
||||
})
|
||||
if (toolsByName.has(executeInput.call.name) && !Object.hasOwn(contextEvent.tools, executeInput.call.name))
|
||||
return Effect.succeed({
|
||||
status: "error",
|
||||
error: { type: "tool.unknown", message: `Tool is not available for this request: ${executeInput.call.name}` },
|
||||
})
|
||||
return toolSet.execute(executeInput)
|
||||
}
|
||||
return {
|
||||
request,
|
||||
resolveToolCall,
|
||||
executeTool,
|
||||
stepLimitReached,
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ const layer = Layer.effect(
|
|||
// Durable publishes are serialized so tool fibers and step settlement never interleave
|
||||
// mid-event.
|
||||
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
||||
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
|
||||
const publish = (event: LLMEvent) => serialized(publisher.publish(event))
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
const providerStream = llm.stream(prepared.request).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
|
|
@ -144,21 +144,18 @@ const layer = Layer.effect(
|
|||
}
|
||||
yield* publish(event)
|
||||
if (LLMEvent.is.toolInputError(event)) {
|
||||
if (prepared.resolveToolCall(event.name).type === "settle") needsContinuation = true
|
||||
if (!prepared.stepLimitReached) needsContinuation = true
|
||||
return
|
||||
}
|
||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||
const tool = prepared.resolveToolCall(event.name)
|
||||
if (tool.type === "reject") {
|
||||
yield* serialized(publisher.failUnsettledTools(tool.error))
|
||||
return
|
||||
}
|
||||
needsContinuation = true
|
||||
// Unavailable calls fail individually through the same execution seam;
|
||||
// continuation depends only on remaining Step allowance.
|
||||
if (!prepared.stepLimitReached) needsContinuation = true
|
||||
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
|
||||
ownedToolFibers.push(
|
||||
yield* Effect.uninterruptibleMask((restore) =>
|
||||
restore(
|
||||
tool.settle({
|
||||
prepared.executeTool({
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
messageID: assistantMessageID,
|
||||
|
|
@ -166,17 +163,7 @@ const layer = Layer.effect(
|
|||
progress: (update) => serialized(publisher.progress(event.id, update)),
|
||||
}),
|
||||
).pipe(
|
||||
Effect.flatMap((settlement) =>
|
||||
publish(
|
||||
LLMEvent.toolResult({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
result: settlement.result,
|
||||
output: settlement.output,
|
||||
}),
|
||||
settlement.error,
|
||||
),
|
||||
),
|
||||
Effect.flatMap((execution) => serialized(publisher.toolExecution(event.id, event.name, execution))),
|
||||
),
|
||||
).pipe(FiberSet.run(toolFibers)),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
|
||||
import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
|
||||
import { Effect } from "effect"
|
||||
import { EventV2 } from "../../event"
|
||||
import { ModelV2 } from "../../model"
|
||||
|
|
@ -11,6 +11,7 @@ import { AgentV2 } from "../../agent"
|
|||
import { Snapshot } from "../../snapshot"
|
||||
import { RelativePath } from "../../schema"
|
||||
import { SessionUsage } from "../usage"
|
||||
import { Tool } from "../../tool/tool"
|
||||
import type { ToolRegistry } from "../../tool/registry"
|
||||
|
||||
type Input = {
|
||||
|
|
@ -25,24 +26,13 @@ type Input = {
|
|||
const record = (value: unknown): Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : { value }
|
||||
|
||||
const message = (value: unknown) => {
|
||||
if (typeof value === "string") return value
|
||||
try {
|
||||
return JSON.stringify(value) ?? String(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
/** Derives canonical model content from a provider-hosted tool result. */
|
||||
const hostedContent = (result: ToolResultValue): Tool.NonEmptyContent => {
|
||||
if (result.type === "content") {
|
||||
const content = Tool.nonEmpty(result.value)
|
||||
if (content !== undefined) return content
|
||||
}
|
||||
}
|
||||
|
||||
type SettledOutput =
|
||||
| { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] }
|
||||
| { readonly error: SessionError.Error }
|
||||
|
||||
const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
|
||||
if (result.type === "error") return { error: { type: "tool.execution", message: message(result.value) } }
|
||||
const settled = value ?? ToolOutput.fromResultValue(result)
|
||||
if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
|
||||
return { structured: record(settled.structured), content: settled.content }
|
||||
return [{ type: "text", text: Tool.stringify(result.value) }]
|
||||
}
|
||||
|
||||
/** Persist one step without executing tools or starting a continuation step. */
|
||||
|
|
@ -58,14 +48,8 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||
progress?: ToolRegistry.Progress
|
||||
}
|
||||
>()
|
||||
const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) => {
|
||||
if (!tool.progress) return {}
|
||||
const first = tool.progress.content[0]
|
||||
return {
|
||||
...(first === undefined ? {} : { content: [first, ...tool.progress.content.slice(1)] as const }),
|
||||
metadata: tool.progress.structured,
|
||||
}
|
||||
}
|
||||
const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) =>
|
||||
tool.progress === undefined ? {} : { metadata: tool.progress }
|
||||
let assistantMessageID = input.assistantMessageID
|
||||
let stepStarted = false
|
||||
let stepFailed = false
|
||||
|
|
@ -254,11 +238,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||
const failTools = Effect.fnUntraced(function* (error: SessionError.Error, mode: "all" | "hosted" | "uncalled") {
|
||||
let failed = false
|
||||
for (const [callID, tool] of tools) {
|
||||
if (
|
||||
tool.settled ||
|
||||
(mode === "hosted" && !tool.providerExecuted) ||
|
||||
(mode === "uncalled" && tool.called)
|
||||
)
|
||||
if (tool.settled || (mode === "hosted" && !tool.providerExecuted) || (mode === "uncalled" && tool.called))
|
||||
continue
|
||||
tool.settled = true
|
||||
failed = true
|
||||
|
|
@ -310,7 +290,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${callID}`))
|
||||
}
|
||||
|
||||
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent, error?: SessionError.Error) {
|
||||
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent) {
|
||||
switch (event.type) {
|
||||
case "step-start":
|
||||
yield* startAssistant()
|
||||
|
|
@ -409,26 +389,27 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||
return
|
||||
}
|
||||
case "tool-result": {
|
||||
// Provider-hosted results only; local executions publish through `toolExecution`.
|
||||
const tool = tools.get(event.id)
|
||||
if (!tool?.called) return yield* Effect.die(new Error(`Tool result before call: ${event.id}`))
|
||||
if (tool.name !== event.name)
|
||||
return yield* Effect.die(new Error(`Tool result name changed for ${event.id}: ${tool.name} -> ${event.name}`))
|
||||
if (tool.settled) {
|
||||
// A late error result is a benign straggler (e.g. after an abort
|
||||
// sweep); a late success would mean double execution, so it dies.
|
||||
if (event.result.type === "error") return
|
||||
return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`))
|
||||
}
|
||||
tool.settled = true
|
||||
const result = error ? { error } : settledOutput(event.output, event.result)
|
||||
const executed = event.providerExecuted === true || tool.providerExecuted
|
||||
const resultState = providerState(event.providerMetadata)
|
||||
if ("error" in result) {
|
||||
if (event.result.type === "error") {
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID: event.id,
|
||||
error: result.error,
|
||||
error: { type: "tool.execution", message: Tool.stringify(event.result.value) },
|
||||
...failureSnapshot(tool),
|
||||
result: event.result,
|
||||
executed,
|
||||
resultState,
|
||||
})
|
||||
|
|
@ -438,8 +419,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID: event.id,
|
||||
...result,
|
||||
...(executed ? { result: event.result } : {}),
|
||||
content: hostedContent(event.result),
|
||||
executed,
|
||||
resultState,
|
||||
})
|
||||
|
|
@ -489,19 +469,63 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||
const tool = tools.get(callID)
|
||||
if (!tool?.called || tool.settled)
|
||||
return yield* Effect.die(new Error(`Tool progress outside running call: ${callID}`))
|
||||
const current = { structured: { ...update.structured }, content: [...update.content] }
|
||||
tool.progress = current
|
||||
tool.progress = update
|
||||
yield* events.publish(SessionEvent.Tool.Progress, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID,
|
||||
...current,
|
||||
metadata: update,
|
||||
})
|
||||
})
|
||||
|
||||
/** Publishes one canonical terminal event for a locally executed tool call. */
|
||||
const toolExecution = Effect.fnUntraced(function* (
|
||||
callID: string,
|
||||
name: string,
|
||||
execution: ToolRegistry.ToolOutcome,
|
||||
) {
|
||||
const tool = tools.get(callID)
|
||||
if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${callID}`))
|
||||
if (tool.name !== name)
|
||||
return yield* Effect.die(new Error(`Tool execution name changed for ${callID}: ${tool.name} -> ${name}`))
|
||||
if (tool.settled) {
|
||||
if (execution.status === "error") return
|
||||
return yield* Effect.die(new Error(`Duplicate tool execution: ${callID}`))
|
||||
}
|
||||
tool.settled = true
|
||||
if (execution.status === "completed") {
|
||||
yield* events.publish(SessionEvent.Tool.Success, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID,
|
||||
content: execution.content,
|
||||
...(execution.metadata === undefined ? {} : { metadata: execution.metadata }),
|
||||
executed: tool.providerExecuted,
|
||||
})
|
||||
return
|
||||
}
|
||||
// An execution-provided snapshot wins; otherwise fall back to retained progress.
|
||||
const snapshot =
|
||||
execution.content !== undefined || execution.metadata !== undefined
|
||||
? {
|
||||
...(execution.content === undefined ? {} : { content: execution.content }),
|
||||
...(execution.metadata === undefined ? {} : { metadata: execution.metadata }),
|
||||
}
|
||||
: failureSnapshot(tool)
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID,
|
||||
error: execution.error,
|
||||
...snapshot,
|
||||
executed: tool.providerExecuted,
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
publish,
|
||||
progress,
|
||||
toolExecution,
|
||||
flush,
|
||||
failAssistant,
|
||||
publishStepFailure,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,4 @@
|
|||
import {
|
||||
Message,
|
||||
ToolCallPart,
|
||||
ToolOutput,
|
||||
ToolResultPart,
|
||||
type ContentPart,
|
||||
type ProviderMetadata,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode-ai/ai"
|
||||
import { Option, Schema } from "effect"
|
||||
import type { ModelV2 } from "../../model"
|
||||
import { SessionMessage } from "../message"
|
||||
|
|
@ -90,15 +83,15 @@ const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: Provider
|
|||
const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined) => {
|
||||
if (tool.state.status === "completed") {
|
||||
// TODO: Materialize remote and managed URIs before provider-history lowering.
|
||||
// ToolOutput.toResultValue rejects unresolved URIs rather than treating them as media bytes.
|
||||
const result =
|
||||
tool.executed === true && tool.state.result !== undefined
|
||||
? tool.state.result
|
||||
: ToolOutput.toResultValue({ structured: tool.state.structured, content: tool.state.content })
|
||||
const content = tool.state.content
|
||||
const single = content.length === 1 ? content[0] : undefined
|
||||
return ToolResultPart.make({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
result,
|
||||
result:
|
||||
single?.type === "text"
|
||||
? { type: "text" as const, value: single.text }
|
||||
: { type: "content" as const, value: content },
|
||||
providerExecuted: tool.executed,
|
||||
providerMetadata,
|
||||
})
|
||||
|
|
@ -107,10 +100,7 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
|
|||
return ToolResultPart.make({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
result:
|
||||
tool.executed === true && tool.state.result !== undefined
|
||||
? tool.state.result
|
||||
: { error: tool.state.error, content: tool.state.content, structured: tool.state.structured },
|
||||
result: { error: tool.state.error, content: tool.state.content ?? [] },
|
||||
resultType: "error",
|
||||
providerExecuted: tool.executed,
|
||||
providerMetadata,
|
||||
|
|
@ -119,8 +109,8 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
|
|||
}
|
||||
|
||||
const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, providerMetadataKey: string) => {
|
||||
const sameModel =
|
||||
String(message.model.providerID) === String(model.providerID) && String(message.model.id) === String(model.id)
|
||||
const sameProvider = String(message.model.providerID) === String(model.providerID)
|
||||
const sameModel = sameProvider && String(message.model.id) === String(model.id)
|
||||
const reuseProviderMetadata = sameModel && message.error === undefined
|
||||
const content = message.content.flatMap((item): ContentPart[] => {
|
||||
if (item.type === "text") return [{ type: "text", text: item.text }]
|
||||
|
|
@ -138,19 +128,21 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
|
|||
: []
|
||||
const reuseToolProviderMetadata =
|
||||
reuseProviderMetadata ||
|
||||
(sameModel &&
|
||||
item.executed === true &&
|
||||
(item.state.status === "completed" || (item.state.status === "error" && item.state.result !== undefined)))
|
||||
(sameModel && item.executed === true && (item.state.status === "completed" || item.state.status === "error"))
|
||||
const call = toolCall(
|
||||
item,
|
||||
reuseToolProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined,
|
||||
)
|
||||
if (item.executed !== true) return [call]
|
||||
// Hosted result payloads are provider-format state, not model state:
|
||||
// replay must survive a model switch within the same provider.
|
||||
const result = toolResult(
|
||||
item,
|
||||
reuseToolProviderMetadata
|
||||
? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState)
|
||||
: undefined,
|
||||
: sameProvider && item.executed === true && item.providerResultState !== undefined
|
||||
? providerMetadata(providerMetadataKey, item.providerResultState)
|
||||
: undefined,
|
||||
)
|
||||
return result ? [call, result] : [call]
|
||||
})
|
||||
|
|
|
|||
|
|
@ -39,8 +39,13 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
|||
}
|
||||
if (cause instanceof PermissionV2.BlockedError) return { type: "permission.rejected", message: cause.message }
|
||||
if (cause instanceof QuestionV2.RejectedError) return { type: "aborted", message: cause.message }
|
||||
if (cause instanceof ToolFailure || cause instanceof Tool.Failure)
|
||||
return cause.error === undefined ? { type: "tool.execution", message: cause.message } : toSessionError(cause.error)
|
||||
if (cause instanceof ToolFailure || cause instanceof Tool.Failure) {
|
||||
if (cause.error === undefined) return { type: "tool.execution", message: cause.message }
|
||||
// The canonical error is the sole model-visible representation, so a cause
|
||||
// with no message must not erase the tool's curated failure message.
|
||||
const unwrapped = toSessionError(cause.error)
|
||||
return unwrapped.message === "" ? { ...unwrapped, type: "tool.execution", message: cause.message } : unwrapped
|
||||
}
|
||||
if (cause instanceof StepFailedError) return cause.error
|
||||
if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message }
|
||||
if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message }
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Global } from "@opencode-ai/util/global"
|
|||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionSchema } from "./session/schema"
|
||||
import { Identifier } from "./util/identifier"
|
||||
import type { ToolOutput } from "@opencode-ai/ai"
|
||||
import type { ToolContent } from "@opencode-ai/ai"
|
||||
|
||||
export const MAX_LINES = 2_000
|
||||
export const MAX_BYTES = 50 * 1024
|
||||
|
|
@ -19,11 +19,11 @@ export const MANAGED_DIRECTORY = "tool-output"
|
|||
export interface BoundInput {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly callID: string
|
||||
readonly output: ToolOutput
|
||||
readonly content: ReadonlyArray<ToolContent>
|
||||
}
|
||||
|
||||
export interface BoundResult {
|
||||
readonly output: ToolOutput
|
||||
readonly content: ReadonlyArray<ToolContent>
|
||||
readonly outputPaths: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
|
|
@ -137,21 +137,14 @@ const layer = Layer.effect(
|
|||
|
||||
const bound = Effect.fn("ToolOutputStore.bound")(function* (input: BoundInput) {
|
||||
const outputLimits = yield* limits()
|
||||
const media = input.output.content.filter((item) => item.type === "file")
|
||||
const text = input.output.content.filter((item) => item.type === "text")
|
||||
const contextual =
|
||||
input.output.content.length === 0
|
||||
? yield* Effect.try({
|
||||
try: () => JSON.stringify(input.output.structured, null, 2) ?? String(input.output.structured),
|
||||
catch: (cause) => new StorageError({ operation: "encode", cause }),
|
||||
})
|
||||
: text.map((item) => item.text).join("")
|
||||
const media = input.content.filter((item) => item.type === "file")
|
||||
const contextual = input.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("")
|
||||
if (
|
||||
lineCount(contextual) <= outputLimits.maxLines &&
|
||||
Buffer.byteLength(contextual, "utf-8") <= outputLimits.maxBytes
|
||||
)
|
||||
return {
|
||||
output: input.output,
|
||||
content: input.content,
|
||||
outputPaths: [],
|
||||
}
|
||||
|
||||
|
|
@ -159,16 +152,13 @@ const layer = Layer.effect(
|
|||
const marker = `... output truncated; full content saved to ${outputPath} ...`
|
||||
|
||||
return {
|
||||
output: {
|
||||
structured: input.output.structured,
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes),
|
||||
},
|
||||
...media,
|
||||
],
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes),
|
||||
},
|
||||
...media,
|
||||
],
|
||||
outputPaths: [outputPath],
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
# Core Tool Architecture
|
||||
|
||||
This folder owns Core's one local tool representation, process and Location registration, effective lookup, and settlement.
|
||||
This folder owns Core's local tools, Location-scoped registrations, effective lookup, execution, and terminal outcomes.
|
||||
|
||||
## Representations
|
||||
|
||||
- `tool.ts` defines the structural canonical `Tool.make({ description, input, output, execute, toModelOutput })` declaration. Shipped built-ins and plugin tools use the same type.
|
||||
- `tool.ts` defines the structural canonical `Tool.make({ description, input, output?, execute })` tool. Executors return model content and metadata alongside declared machine output. Shipped built-ins and plugin tools use the same type.
|
||||
- `tools.ts` exposes the registration-only `Tools.Service` view used by Location producers.
|
||||
- `registry.ts` stores only canonical Location registrations, derives definitions, invokes tools, and applies generic output bounding.
|
||||
- `registry.ts` stores only canonical Location registrations, derives LLM definitions, executes tools, and applies generic output bounding.
|
||||
|
||||
Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path.
|
||||
|
||||
## Construction
|
||||
|
||||
Tool schemas and projection use `input` and `output` terminology. A tool value carries its schemas, executor, projection, and optional catalog permission directly so separately loaded plugin package instances can exchange it structurally.
|
||||
Tool schemas use `input` and `output` terminology. A tool carries schemas and executable behavior without public identity. A registration binds its name, namespace, CodeMode placement, and optional catalog permission action.
|
||||
|
||||
Location-scoped built-in layers acquire `PermissionV2.Service` and every other required Location service while the layer is constructed. The executor captures those services. Permission sources are always constructed from the canonical invocation context:
|
||||
|
||||
```ts
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.assistantMessageID,
|
||||
callID: context.toolCallID,
|
||||
messageID: context.messageID,
|
||||
callID: context.callID,
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -42,13 +42,13 @@ Registrations are scoped:
|
|||
|
||||
## Permissions
|
||||
|
||||
The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `patch` declare the shared `edit` action.
|
||||
The registry has no `PermissionV2.Service` dependency and performs no execution authorization. Registration options may attach a permission action solely to preserve whole-tool definition filtering. Most registrations default to their effective name; `edit`, `write`, and `patch` use the shared `edit` action.
|
||||
|
||||
Definition filtering is catalog visibility, not execution authorization. A call still executes the captured leaf policy if it reaches settlement.
|
||||
Tool filtering is catalog visibility, not execution authorization. A call still executes the captured tool's leaf policy if it reaches execution.
|
||||
|
||||
## Output
|
||||
|
||||
Built-ins return complete validated domain output. `ToolRegistry.Materialization.settle` is the only execution and generic model-output bounding boundary and owns managed retention paths.
|
||||
Built-ins return complete tool responses. `ToolRegistry.ToolSet.execute` is the only local execution and generic model-output bounding boundary and owns managed retention paths.
|
||||
|
||||
Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss, but it does not run model-output truncation or return a managed `outputPath`.
|
||||
|
||||
|
|
|
|||
|
|
@ -97,15 +97,11 @@ export const Plugin = {
|
|||
.transform((draft) =>
|
||||
draft.add(
|
||||
name,
|
||||
Tool.withPermission(
|
||||
Tool.make({
|
||||
Tool.make({
|
||||
description:
|
||||
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ input, output }) => [
|
||||
{ type: "text", text: toModelOutput(output, input.oldString, input.newString) },
|
||||
],
|
||||
execute: (input, context) => {
|
||||
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(
|
||||
|
|
@ -207,12 +203,16 @@ export const Plugin = {
|
|||
],
|
||||
replacements,
|
||||
} satisfies Output
|
||||
})
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelOutput(output, input.oldString, input.newString),
|
||||
metadata: { files: output.files },
|
||||
})),
|
||||
)
|
||||
},
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
{ codemode: false },
|
||||
{ codemode: false, permission: "edit" },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
export * as ExecuteTool from "./execute"
|
||||
export type { Registration } from "./tool"
|
||||
|
||||
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
|
||||
import { ToolOutput } from "@opencode-ai/ai"
|
||||
import type { ToolContent } from "@opencode-ai/ai"
|
||||
import { Effect, Ref, Schema } from "effect"
|
||||
import { definition, make, settle, type AnyTool } from "./tool"
|
||||
import { execute, make, toLLMDefinition, type Content, type Metadata, type Registration } from "./tool"
|
||||
|
||||
const ExecuteFile = Schema.Struct({
|
||||
data: Schema.String,
|
||||
|
|
@ -14,16 +15,11 @@ const ExecuteFile = Schema.Struct({
|
|||
const ExecuteCall = Schema.Struct({
|
||||
tool: Schema.String,
|
||||
status: Schema.Literals(["running", "completed", "error"]),
|
||||
input: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
input: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)),
|
||||
})
|
||||
|
||||
type ExecuteCall = typeof ExecuteCall.Type
|
||||
|
||||
const ExecuteMetadata = Schema.Struct({
|
||||
toolCalls: Schema.Array(ExecuteCall),
|
||||
error: Schema.optionalKey(Schema.Literal(true)),
|
||||
})
|
||||
|
||||
const ExecuteOutput = Schema.Struct({
|
||||
output: Schema.String,
|
||||
toolCalls: Schema.Array(ExecuteCall),
|
||||
|
|
@ -36,12 +32,6 @@ type CollectedFiles = {
|
|||
readonly files: Array<typeof ExecuteFile.Type>
|
||||
}
|
||||
|
||||
export interface Registration {
|
||||
readonly tool: AnyTool
|
||||
readonly name: string
|
||||
readonly namespace?: string
|
||||
}
|
||||
|
||||
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
|
||||
const description = [
|
||||
"Run JavaScript in a confined Code Mode runtime through { code }.",
|
||||
|
|
@ -55,20 +45,6 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
|||
description,
|
||||
input: CodeMode.Input,
|
||||
output: ExecuteOutput,
|
||||
structured: ExecuteMetadata,
|
||||
toStructuredOutput: ({ output }) => ({
|
||||
toolCalls: output.toolCalls,
|
||||
...(output.error ? { error: true as const } : {}),
|
||||
}),
|
||||
toModelOutput: ({ output }) => [
|
||||
{ type: "text" as const, text: output.output },
|
||||
...output.files.map((file) => ({
|
||||
type: "file" as const,
|
||||
data: file.data,
|
||||
mime: file.mime,
|
||||
...(file.name === undefined ? {} : { name: file.name }),
|
||||
})),
|
||||
],
|
||||
execute: ({ code }, context) =>
|
||||
Effect.gen(function* () {
|
||||
const callIndex = yield* Ref.make(0)
|
||||
|
|
@ -85,21 +61,17 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
|||
(name, registration, input) =>
|
||||
Effect.gen(function* () {
|
||||
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
|
||||
const output = yield* settle(
|
||||
registration.tool,
|
||||
{ type: "tool-call", id: context.callID, name, input },
|
||||
{
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
messageID: context.messageID,
|
||||
callID: context.callID,
|
||||
progress: context.progress,
|
||||
},
|
||||
).pipe(Effect.mapError((failure) => toolError(failure.message, failure)))
|
||||
const outputFileParts = outputFiles(output)
|
||||
const executed = yield* execute(registration.tool, input, {
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
messageID: context.messageID,
|
||||
callID: context.callID,
|
||||
progress: context.progress,
|
||||
}).pipe(Effect.mapError((failure) => toolError(failure.message, failure)))
|
||||
const outputFileParts = outputFiles(executed.content)
|
||||
if (outputFileParts.length > 0)
|
||||
yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }])
|
||||
return output.structured
|
||||
return executed.output
|
||||
}),
|
||||
{
|
||||
onToolCallStart: ({ index, name, input }) =>
|
||||
|
|
@ -126,7 +98,30 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
|||
.toSorted((left, right) => left.index - right.index)
|
||||
.flatMap((item) => item.files)
|
||||
const output = formatResult(result)
|
||||
return { output, toolCalls, files: collected, ...(result.ok ? {} : { error: true as const }) }
|
||||
const value: typeof ExecuteOutput.Type = {
|
||||
output,
|
||||
toolCalls,
|
||||
files: collected,
|
||||
...(result.ok ? {} : { error: true }),
|
||||
}
|
||||
const content: [Content, ...Content[]] = [{ type: "text", text: value.output }]
|
||||
content.push(
|
||||
...value.files.map((file) => ({
|
||||
type: "file" as const,
|
||||
data: file.data,
|
||||
mime: file.mime,
|
||||
...(file.name === undefined ? {} : { name: file.name }),
|
||||
})),
|
||||
)
|
||||
const metadata: Metadata = {
|
||||
toolCalls: value.toolCalls,
|
||||
...(value.error ? { error: true } : {}),
|
||||
}
|
||||
return {
|
||||
output: value,
|
||||
content,
|
||||
metadata,
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
|
@ -137,28 +132,30 @@ export const instructions = (registrations: ReadonlyMap<string, Registration>) =
|
|||
|
||||
function runtime(
|
||||
registrations: ReadonlyMap<string, Registration>,
|
||||
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
|
||||
executeTool: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
|
||||
hooks?: CodeMode.ToolCallHooks,
|
||||
) {
|
||||
const tools: Record<string, Tool.Definition<never>> = {}
|
||||
const tools: Record<string, Tool.Tool<never>> = {}
|
||||
for (const [name, registration] of registrations) {
|
||||
const child = definition(name, registration.tool)
|
||||
const path = registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}`
|
||||
const child = toLLMDefinition(name, registration.tool)
|
||||
const path =
|
||||
registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}`
|
||||
tools[path] = Tool.make({
|
||||
description: child.description,
|
||||
input: child.inputSchema,
|
||||
output: child.outputSchema,
|
||||
run: (input) => invoke(name, registration, input),
|
||||
execute: (input) => executeTool(name, registration, input),
|
||||
})
|
||||
}
|
||||
return CodeMode.make<typeof tools>({ tools, ...hooks })
|
||||
}
|
||||
|
||||
function displayInput(input: unknown): Record<string, unknown> | undefined {
|
||||
// Tool inputs arrive as parsed JSON, so the JSON value cast is a boundary fact.
|
||||
function displayInput(input: unknown): Record<string, typeof Schema.Json.Type> | undefined {
|
||||
if (input === null || input === undefined) return
|
||||
if (typeof input !== "object" || Array.isArray(input)) return { input }
|
||||
if (typeof input !== "object" || Array.isArray(input)) return { input: input as typeof Schema.Json.Type }
|
||||
if (Object.keys(input).length === 0) return
|
||||
return input as Record<string, unknown>
|
||||
return input as Record<string, typeof Schema.Json.Type>
|
||||
}
|
||||
|
||||
function formatResult(result: CodeMode.Result) {
|
||||
|
|
@ -180,8 +177,8 @@ function formatValue(value: CodeMode.DataValue) {
|
|||
return JSON.stringify(value, null, 2) ?? String(value)
|
||||
}
|
||||
|
||||
function outputFiles(output: ToolOutput): Array<typeof ExecuteFile.Type> {
|
||||
return output.content.flatMap((part) => {
|
||||
function outputFiles(content: ReadonlyArray<ToolContent>): Array<typeof ExecuteFile.Type> {
|
||||
return content.flatMap((part) => {
|
||||
if (part.type !== "file") return []
|
||||
const prefix = `data:${part.mime};base64,`
|
||||
if (!part.uri.startsWith(prefix)) return []
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { FileSystem } from "../filesystem"
|
|||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "../location"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { NonNegativeInt, RelativePath } from "../schema"
|
||||
import { RelativePath } from "../schema"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
|
|
@ -25,9 +25,6 @@ export const Input = Schema.Struct({
|
|||
})
|
||||
|
||||
export const Output = Schema.Array(FileSystem.Entry)
|
||||
const StructuredOutput = Schema.Struct({
|
||||
count: NonNegativeInt,
|
||||
})
|
||||
type ModelOutput = typeof Output.Encoded
|
||||
|
||||
/** Format raw search results into the concise line-oriented output models expect. */
|
||||
|
|
@ -54,16 +51,6 @@ export const Plugin = {
|
|||
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: StructuredOutput,
|
||||
toStructuredOutput: ({ output }) => ({ count: output.length }),
|
||||
toModelOutput: ({ output }) => [
|
||||
{
|
||||
type: "text",
|
||||
text: toModelOutput(
|
||||
output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
|
||||
),
|
||||
},
|
||||
],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
|
|
@ -104,6 +91,13 @@ export const Plugin = {
|
|||
),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelOutput(
|
||||
output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
|
||||
),
|
||||
metadata: { count: output.length },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
|||
import { Location } from "../location"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { NonNegativeInt, RelativePath } from "../schema"
|
||||
import { RelativePath } from "../schema"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
export const name = "grep"
|
||||
|
|
@ -30,9 +30,6 @@ export const Input = Schema.Struct({
|
|||
})
|
||||
|
||||
export const Output = Schema.Array(FileSystem.Match)
|
||||
const StructuredOutput = Schema.Struct({
|
||||
matches: NonNegativeInt,
|
||||
})
|
||||
type ModelOutput = typeof Output.Encoded
|
||||
|
||||
/** Format raw search matches into the familiar concise model output. */
|
||||
|
|
@ -68,19 +65,6 @@ export const Plugin = {
|
|||
"Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: StructuredOutput,
|
||||
toStructuredOutput: ({ output }) => ({ matches: output.length }),
|
||||
toModelOutput: ({ output }) => [
|
||||
{
|
||||
type: "text",
|
||||
text: toModelOutput(
|
||||
output.map((match) => ({
|
||||
...match,
|
||||
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
|
||||
})),
|
||||
),
|
||||
},
|
||||
],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
|
|
@ -135,6 +119,16 @@ export const Plugin = {
|
|||
),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelOutput(
|
||||
output.map((match) => ({
|
||||
...match,
|
||||
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
|
||||
})),
|
||||
),
|
||||
metadata: { matches: output.length },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
|
|
|
|||
|
|
@ -1,33 +1,14 @@
|
|||
export * as ToolHooks from "./hooks"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessage } from "../session/message"
|
||||
import { State } from "../state"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import type { ToolOutput, ToolResultValue } from "@opencode-ai/ai"
|
||||
import type { Tool } from "./tool"
|
||||
|
||||
export interface BeforeEvent {
|
||||
readonly tool: string
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly callID: string
|
||||
input: unknown
|
||||
}
|
||||
export type BeforeEvent = Tool.ToolExecuteBeforeEvent
|
||||
|
||||
export interface AfterEvent {
|
||||
readonly tool: string
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly callID: string
|
||||
readonly input: unknown
|
||||
result: ToolResultValue
|
||||
output?: ToolOutput
|
||||
outputPaths?: ReadonlyArray<string>
|
||||
}
|
||||
/** The canonical execution outcome. Hooks never observe the raw domain output. */
|
||||
export type AfterEvent = Tool.ToolExecuteAfterEvent
|
||||
|
||||
export interface Interface {
|
||||
readonly hook: {
|
||||
|
|
|
|||
|
|
@ -32,86 +32,90 @@ export const layer = Layer.effectDiscard(
|
|||
// registry never has a gap where MCP tools disappear mid-swap.
|
||||
const reconcile = lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const groups = new Map<string, { tools: Record<string, Tool.AnyTool>; codemode: boolean }>()
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
tools: Record<string, Tool.Any>
|
||||
codemode: boolean
|
||||
}
|
||||
>()
|
||||
for (const tool of yield* mcp.tools()) {
|
||||
const group = groups.get(tool.server) ?? { tools: {}, codemode: tool.codemode !== false }
|
||||
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
|
||||
group.tools[tool.name] = Tool.withPermission(
|
||||
Tool.make({
|
||||
description: tool.description ?? "",
|
||||
jsonSchema: {
|
||||
...schema,
|
||||
type: "object",
|
||||
properties: schema.properties ?? {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
outputSchema: tool.outputSchema as JsonSchema.JsonSchema | undefined,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: name(tool.server, tool.name),
|
||||
resources: ["*"],
|
||||
save: ["*"],
|
||||
metadata: {},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: {
|
||||
type: "tool",
|
||||
messageID: context.messageID,
|
||||
callID: context.callID,
|
||||
},
|
||||
group.tools[tool.name] = Tool.make({
|
||||
description: tool.description ?? "",
|
||||
input: {
|
||||
...schema,
|
||||
type: "object",
|
||||
properties: schema.properties ?? {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: name(tool.server, tool.name),
|
||||
resources: ["*"],
|
||||
save: ["*"],
|
||||
metadata: {},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: {
|
||||
type: "tool",
|
||||
messageID: context.messageID,
|
||||
callID: context.callID,
|
||||
},
|
||||
})
|
||||
const result = yield* mcp
|
||||
.callTool({
|
||||
server: tool.server,
|
||||
name: tool.name,
|
||||
args: (input ?? {}) as Record<string, unknown>,
|
||||
})
|
||||
const result = yield* mcp
|
||||
.callTool({
|
||||
server: tool.server,
|
||||
name: tool.name,
|
||||
args: (input ?? {}) as Record<string, unknown>,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTags({
|
||||
"MCP.NotFoundError": (error) =>
|
||||
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
|
||||
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
|
||||
}),
|
||||
)
|
||||
if (result.isError)
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
result.content
|
||||
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||
.join("\n")
|
||||
.trim() || "MCP tool returned an error",
|
||||
})
|
||||
const content = result.content.map((part) =>
|
||||
part.type === "text"
|
||||
? { type: "text" as const, text: part.text }
|
||||
: { type: "file" as const, data: part.data, mime: part.mimeType },
|
||||
.pipe(
|
||||
Effect.catchTags({
|
||||
"MCP.NotFoundError": (error) =>
|
||||
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
|
||||
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
|
||||
}),
|
||||
)
|
||||
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
return {
|
||||
structured: result.structured ?? (text === "" ? null : text),
|
||||
content,
|
||||
}
|
||||
}).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
|
||||
),
|
||||
if (result.isError)
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
result.content
|
||||
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||
.join("\n")
|
||||
.trim() || "MCP tool returned an error",
|
||||
})
|
||||
const content = result.content.map((part) =>
|
||||
part.type === "text"
|
||||
? { type: "text" as const, text: part.text }
|
||||
: { type: "file" as const, data: part.data, mime: part.mimeType },
|
||||
)
|
||||
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
return {
|
||||
output: result.structured ?? (text === "" ? null : text),
|
||||
...(content.length === 0 ? {} : { content: content as [Tool.Content, ...Tool.Content[]] }),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
|
||||
),
|
||||
}),
|
||||
name(tool.server, tool.name),
|
||||
)
|
||||
),
|
||||
})
|
||||
groups.set(tool.server, group)
|
||||
}
|
||||
const next = yield* Scope.fork(scope)
|
||||
yield* Effect.forEach(
|
||||
groups,
|
||||
([server, group]) => tools.register(group.tools, { namespace: namespace(server), codemode: group.codemode }),
|
||||
{
|
||||
discard: true,
|
||||
},
|
||||
).pipe(Scope.provide(next), Effect.orDie)
|
||||
yield* tools
|
||||
.registerBatch(
|
||||
Array.from(groups, ([server, group]) => ({
|
||||
tools: group.tools,
|
||||
options: { namespace: namespace(server), codemode: group.codemode },
|
||||
})),
|
||||
)
|
||||
.pipe(Scope.provide(next), Effect.orDie)
|
||||
if (current) yield* Scope.close(current, Exit.void)
|
||||
current = next
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -75,12 +75,10 @@ export const Plugin = {
|
|||
.transform((draft) =>
|
||||
draft.add(
|
||||
name,
|
||||
Tool.withPermission(
|
||||
Tool.make({
|
||||
Tool.make({
|
||||
description: DESCRIPTION,
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
|
||||
execute: (input, context) => {
|
||||
const applied: Array<typeof Applied.Type> = []
|
||||
const fail = (path: string, error?: unknown) => {
|
||||
|
|
@ -278,12 +276,17 @@ export const Plugin = {
|
|||
{ discard: true },
|
||||
)
|
||||
return { applied, files: patchFiles }
|
||||
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch", error))))
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: toModelOutput(output),
|
||||
metadata: { files: output.files },
|
||||
})),
|
||||
Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch", error))),
|
||||
)
|
||||
},
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
{ codemode: false },
|
||||
{ codemode: false, permission: "edit" },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
|
|
|||
|
|
@ -63,9 +63,6 @@ export const Plugin = {
|
|||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ input, output }) => [
|
||||
{ type: "text", text: toModelOutput(input.questions, output.answers) },
|
||||
],
|
||||
execute: (input, context) =>
|
||||
permission
|
||||
.assert({
|
||||
|
|
@ -95,13 +92,18 @@ export const Plugin = {
|
|||
),
|
||||
Effect.flatMap((state) => {
|
||||
if (state.status === "cancelled") return Effect.die(new CancelledError())
|
||||
return Effect.succeed({
|
||||
const output = {
|
||||
answers: input.questions.map((_, index): QuestionV2.Answer => {
|
||||
const value = state.answer[`q${index}`]
|
||||
if (value === undefined) return []
|
||||
if (typeof value === "object") return Array.from(value)
|
||||
return [String(value)]
|
||||
}),
|
||||
}
|
||||
return Effect.succeed({
|
||||
output,
|
||||
content: toModelOutput(input.questions, output.answers),
|
||||
metadata: { answers: output.answers },
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -48,20 +48,6 @@ export const Plugin = {
|
|||
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: Schema.toEncoded(Output),
|
||||
// Image base64 reaches the model through content items (normalized generically
|
||||
// at tool settlement); persisting a second copy in structured would store the
|
||||
// original unresized bytes in the message row.
|
||||
toStructuredOutput: ({ output }) =>
|
||||
"encoding" in output && output.encoding === "base64" ? { ...output, content: "" } : output,
|
||||
toModelOutput: ({ input, output }) => {
|
||||
if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime))
|
||||
return []
|
||||
return [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", data: output.content, mime: output.mime, name: input.path },
|
||||
]
|
||||
},
|
||||
execute: (input, context) => {
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
|
|
@ -125,6 +111,20 @@ export const Plugin = {
|
|||
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
|
||||
return content
|
||||
}).pipe(
|
||||
Effect.map((output) => {
|
||||
// Image base64 reaches the model through content items; avoid a second
|
||||
// unresized copy in model text.
|
||||
const content =
|
||||
"encoding" in output && output.encoding === "base64"
|
||||
? SUPPORTED_IMAGE_MIMES.has(output.mime)
|
||||
? ([
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", data: output.content, mime: output.mime, name: input.path },
|
||||
] as const)
|
||||
: JSON.stringify({ ...output, content: "" }, null, 2)
|
||||
: JSON.stringify(output, null, 2)
|
||||
return { output, content }
|
||||
}),
|
||||
Effect.mapError((error) => {
|
||||
const message =
|
||||
error instanceof ReadToolFileSystem.BinaryFileError ||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export * as ToolRegistry from "./registry"
|
||||
|
||||
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/ai"
|
||||
import { Context, Effect, Layer, Scope, Semaphore } from "effect"
|
||||
import { type ToolCall, type ToolContent, type ToolDefinition } from "@opencode-ai/ai"
|
||||
import { Context, Effect, Layer, Schema, Scope, Semaphore } from "effect"
|
||||
import type { AgentV2 } from "../agent"
|
||||
import { Image } from "../image"
|
||||
import { PermissionV2 } from "../permission"
|
||||
|
|
@ -10,19 +10,10 @@ import { SessionSchema } from "../session/schema"
|
|||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { Wildcard } from "../util/wildcard"
|
||||
import { CodeMode } from "../codemode"
|
||||
import {
|
||||
definition,
|
||||
permission,
|
||||
registrationEntries,
|
||||
RegistrationError,
|
||||
settle,
|
||||
validateNamespace,
|
||||
type AnyTool,
|
||||
} from "./tool"
|
||||
import { Tool, nonEmpty, registrationEntries, toLLMDefinition, validateName, validateNamespace } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { ToolHooks } from "./hooks"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { toSessionError } from "../session/to-session-error"
|
||||
|
||||
export type ExecuteInput = {
|
||||
|
|
@ -33,38 +24,42 @@ export type ExecuteInput = {
|
|||
readonly progress?: (update: Progress) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface Progress {
|
||||
readonly structured: Readonly<Record<string, unknown>>
|
||||
readonly content: ToolOutput["content"]
|
||||
}
|
||||
/** Live replacement metadata for a running tool. */
|
||||
export type Progress = Tool.Metadata
|
||||
|
||||
export interface Interface {
|
||||
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
|
||||
readonly snapshot: (permissions?: PermissionV2.Ruleset) => Effect.Effect<ToolSet>
|
||||
/** Internal registration capability exposed publicly only through Tools.Service. */
|
||||
readonly register: (
|
||||
tools: Readonly<Record<string, AnyTool>>,
|
||||
tools: Readonly<Record<string, Tool.Any>>,
|
||||
options?: Tools.RegisterOptions,
|
||||
) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
||||
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
|
||||
/** Internal atomic registration capability used by plugin transforms. */
|
||||
readonly registerBatch: (
|
||||
registrations: ReadonlyArray<{
|
||||
readonly tools: Readonly<Record<string, AnyTool>>
|
||||
readonly tools: Readonly<Record<string, Tool.Any>>
|
||||
readonly options?: Tools.RegisterOptions
|
||||
}>,
|
||||
) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
||||
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
|
||||
}
|
||||
|
||||
export interface Materialization {
|
||||
/**
|
||||
* One request-scoped snapshot pairing advertised definitions with captured
|
||||
* tools. A model request executes exactly the tool values it advertised
|
||||
* even if registration changes while the request is in flight.
|
||||
*/
|
||||
export interface ToolSet {
|
||||
readonly definitions: ReadonlyArray<ToolDefinition>
|
||||
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement, ToolOutputStore.Error>
|
||||
readonly execute: (input: ExecuteInput) => Effect.Effect<ToolOutcome, ToolOutputStore.Error>
|
||||
}
|
||||
|
||||
export interface Settlement {
|
||||
readonly result: ToolResultValue
|
||||
readonly output?: ToolOutput
|
||||
readonly outputPaths?: ReadonlyArray<string>
|
||||
readonly error?: SessionError.Error
|
||||
}
|
||||
/**
|
||||
* The canonical outcome of one local tool execution. `output` is the validated
|
||||
* machine value for Code Mode and remains ephemeral; durable publication drops it.
|
||||
*/
|
||||
export type ToolOutcome =
|
||||
| (Extract<Tool.Outcome, { readonly status: "completed" }> & { readonly output?: unknown })
|
||||
| Extract<Tool.Outcome, { readonly status: "error" }>
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
|
||||
|
||||
|
|
@ -76,26 +71,24 @@ const registryLayer = Layer.effect(
|
|||
const image = yield* Image.Service
|
||||
const codeMode = yield* CodeMode.Service
|
||||
|
||||
type NormalizedItem = ToolOutput["content"][number] | "decode" | "size"
|
||||
const normalizeImages = Effect.fn("ToolRegistry.normalizeImages")(function* (content: ToolOutput["content"]) {
|
||||
type NormalizedItem = ToolContent | "decode" | "size"
|
||||
const normalizeImages = Effect.fn("ToolRegistry.normalizeImages")(function* (content: ReadonlyArray<ToolContent>) {
|
||||
const normalized = yield* Effect.forEach(content, (item): Effect.Effect<NormalizedItem> => {
|
||||
if (item.type !== "file" || !item.mime.startsWith("image/")) return Effect.succeed(item)
|
||||
// RFC 2397 permits parameters between the mime and ";base64".
|
||||
const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1]
|
||||
if (base64 === undefined) return Effect.succeed(item)
|
||||
const resource = item.name ?? `${item.mime} tool output`
|
||||
return image
|
||||
.normalize(resource, { uri: resource, content: base64, encoding: "base64", mime: item.mime })
|
||||
.pipe(
|
||||
Effect.map((result) => ({
|
||||
...item,
|
||||
uri: `data:${result.mime};base64,${result.content}`,
|
||||
mime: result.mime,
|
||||
})),
|
||||
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(item)),
|
||||
Effect.catchTag("Image.DecodeError", () => Effect.succeed("decode" as const)),
|
||||
Effect.catchTag("Image.SizeError", () => Effect.succeed("size" as const)),
|
||||
)
|
||||
return image.normalize(resource, { uri: resource, content: base64, encoding: "base64", mime: item.mime }).pipe(
|
||||
Effect.map((result) => ({
|
||||
...item,
|
||||
uri: `data:${result.mime};base64,${result.content}`,
|
||||
mime: result.mime,
|
||||
})),
|
||||
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(item)),
|
||||
Effect.catchTag("Image.DecodeError", () => Effect.succeed("decode" as const)),
|
||||
Effect.catchTag("Image.SizeError", () => Effect.succeed("size" as const)),
|
||||
)
|
||||
})
|
||||
const note = (reason: "decode" | "size", text: string) => {
|
||||
const count = normalized.filter((item) => item === reason).length
|
||||
|
|
@ -108,16 +101,24 @@ const registryLayer = Layer.effect(
|
|||
...note("size", "could not be resized below the image size limit."),
|
||||
]
|
||||
})
|
||||
type Registration = {
|
||||
readonly tool: AnyTool
|
||||
readonly name: string
|
||||
readonly namespace?: string
|
||||
}
|
||||
|
||||
// Invalid or oversized metadata is dropped with a warning; it never fails a
|
||||
// successful side-effecting tool.
|
||||
const validMetadata = Effect.fnUntraced(function* (tool: string, metadata: Tool.Metadata | undefined) {
|
||||
if (metadata === undefined) return undefined
|
||||
const limits = yield* resources.limits()
|
||||
const valid = Tool.jsonMetadata(metadata, limits.maxBytes)
|
||||
if (valid === undefined)
|
||||
yield* Effect.logWarning("dropping invalid or oversized tool metadata").pipe(Effect.annotateLogs({ tool }))
|
||||
return valid
|
||||
})
|
||||
|
||||
type Registration = Tool.Registration
|
||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
||||
const registrationLock = Semaphore.makeUnsafe(1)
|
||||
|
||||
const settleTool = Effect.fn("ToolRegistry.settleTool")(function* (input: ExecuteInput, tool: AnyTool) {
|
||||
// Hooks fire only for hosted/local tools; provider-executed calls never reach settleTool.
|
||||
const executeTool = Effect.fn("ToolRegistry.executeTool")(function* (input: ExecuteInput, tool: Tool.Any) {
|
||||
// Hooks fire only for hosted/local tools; provider-executed calls never reach executeTool.
|
||||
const beforeEvent: ToolHooks.BeforeEvent = {
|
||||
tool: input.call.name,
|
||||
sessionID: input.sessionID,
|
||||
|
|
@ -127,76 +128,100 @@ const registryLayer = Layer.effect(
|
|||
input: input.call.input,
|
||||
}
|
||||
yield* toolHooks.runBefore(beforeEvent)
|
||||
const pending = yield* settle(
|
||||
tool,
|
||||
{ ...input.call, input: beforeEvent.input },
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
messageID: input.messageID,
|
||||
callID: input.call.id,
|
||||
progress: (update) => {
|
||||
const progress = input.progress
|
||||
if (!progress) return Effect.void
|
||||
return normalizeImages(
|
||||
(update.content ?? []).map((part) =>
|
||||
part.type === "text"
|
||||
? { type: "text" as const, text: part.text }
|
||||
: {
|
||||
type: "file" as const,
|
||||
uri: `data:${part.mime};base64,${part.data}`,
|
||||
mime: part.mime,
|
||||
name: part.name,
|
||||
},
|
||||
),
|
||||
).pipe(Effect.flatMap((content) => progress({ structured: update.structured, content })))
|
||||
},
|
||||
const execution = yield* Tool.execute(tool, beforeEvent.input, {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
messageID: input.messageID,
|
||||
callID: input.call.id,
|
||||
progress: (metadata) => {
|
||||
const progress = input.progress
|
||||
if (!progress) return Effect.void
|
||||
return validMetadata(input.call.name, metadata).pipe(
|
||||
Effect.flatMap((valid) => (valid === undefined ? Effect.void : progress(valid))),
|
||||
)
|
||||
},
|
||||
).pipe(
|
||||
Effect.map((output) => ({ output })),
|
||||
Effect.catchTag("LLM.ToolFailure", (failure) =>
|
||||
Effect.succeed({
|
||||
result: { type: "error" as const, value: failure.message },
|
||||
error: toSessionError(failure),
|
||||
}),
|
||||
),
|
||||
}).pipe(
|
||||
Effect.map((value) => ({ value })),
|
||||
Effect.catchTag("LLM.ToolFailure", (failure) => Effect.succeed({ failure: toSessionError(failure) })),
|
||||
)
|
||||
let settlement: Settlement
|
||||
if ("result" in pending) {
|
||||
settlement = pending
|
||||
} else {
|
||||
|
||||
const outcome: ToolOutcome = yield* Effect.gen(function* () {
|
||||
if ("failure" in execution) return { status: "error" as const, error: execution.failure }
|
||||
const bounded = yield* resources.bound({
|
||||
sessionID: input.sessionID,
|
||||
callID: input.call.id,
|
||||
output: { structured: pending.output.structured, content: yield* normalizeImages(pending.output.content) },
|
||||
content: yield* normalizeImages(execution.value.content),
|
||||
})
|
||||
const result = ToolOutput.toResultValue(bounded.output)
|
||||
settlement =
|
||||
result.type === "error"
|
||||
? bounded.outputPaths.length > 0
|
||||
? { result, outputPaths: bounded.outputPaths }
|
||||
: { result }
|
||||
: bounded.outputPaths.length > 0
|
||||
? { result, output: bounded.output, outputPaths: bounded.outputPaths }
|
||||
: { result, output: bounded.output }
|
||||
}
|
||||
const afterEvent: ToolHooks.AfterEvent = {
|
||||
const metadata = yield* validMetadata(input.call.name, execution.value.metadata)
|
||||
return {
|
||||
status: "completed" as const,
|
||||
...(execution.value.output === undefined ? {} : { output: execution.value.output }),
|
||||
content: nonEmpty(bounded.content) ?? execution.value.content,
|
||||
...(metadata === undefined ? {} : { metadata }),
|
||||
...(bounded.outputPaths.length > 0 ? { outputPaths: bounded.outputPaths } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
const base = {
|
||||
tool: input.call.name,
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
messageID: input.messageID,
|
||||
callID: input.call.id,
|
||||
input: beforeEvent.input,
|
||||
result: settlement.result,
|
||||
output: settlement.output,
|
||||
outputPaths: settlement.outputPaths,
|
||||
}
|
||||
const afterEvent: ToolHooks.AfterEvent =
|
||||
outcome.status === "completed"
|
||||
? {
|
||||
...base,
|
||||
status: "completed",
|
||||
content: outcome.content,
|
||||
...(outcome.metadata === undefined ? {} : { metadata: outcome.metadata }),
|
||||
...(outcome.outputPaths === undefined ? {} : { outputPaths: outcome.outputPaths }),
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
status: "error",
|
||||
error: outcome.error,
|
||||
...(outcome.content === undefined ? {} : { content: outcome.content }),
|
||||
...(outcome.metadata === undefined ? {} : { metadata: outcome.metadata }),
|
||||
...(outcome.outputPaths === undefined ? {} : { outputPaths: outcome.outputPaths }),
|
||||
}
|
||||
yield* toolHooks.runAfter(afterEvent)
|
||||
const afterMetadata = yield* validMetadata(input.call.name, afterEvent.metadata)
|
||||
const afterContent = yield* Effect.gen(function* () {
|
||||
if (
|
||||
afterEvent.content === undefined ||
|
||||
(outcome.status === "completed" && afterEvent.content === outcome.content)
|
||||
)
|
||||
return { content: afterEvent.content, outputPaths: afterEvent.outputPaths }
|
||||
const bounded = yield* resources.bound({
|
||||
sessionID: input.sessionID,
|
||||
callID: input.call.id,
|
||||
content: yield* normalizeImages(afterEvent.content),
|
||||
})
|
||||
return {
|
||||
content: nonEmpty(bounded.content),
|
||||
outputPaths:
|
||||
bounded.outputPaths.length === 0
|
||||
? afterEvent.outputPaths
|
||||
: Array.from(new Set([...(afterEvent.outputPaths ?? []), ...bounded.outputPaths])),
|
||||
}
|
||||
})
|
||||
if (afterEvent.status === "completed")
|
||||
return {
|
||||
status: "completed" as const,
|
||||
...(outcome.status === "completed" && outcome.output !== undefined ? { output: outcome.output } : {}),
|
||||
content: afterContent.content ?? afterEvent.content,
|
||||
...(afterMetadata === undefined ? {} : { metadata: afterMetadata }),
|
||||
...(afterContent.outputPaths === undefined ? {} : { outputPaths: afterContent.outputPaths }),
|
||||
}
|
||||
return {
|
||||
result: afterEvent.result,
|
||||
...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}),
|
||||
...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}),
|
||||
...(settlement.error !== undefined ? { error: settlement.error } : {}),
|
||||
status: "error" as const,
|
||||
error: afterEvent.error,
|
||||
...(afterContent.content === undefined ? {} : { content: afterContent.content }),
|
||||
...(afterMetadata === undefined ? {} : { metadata: afterMetadata }),
|
||||
...(afterContent.outputPaths === undefined ? {} : { outputPaths: afterContent.outputPaths }),
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -205,12 +230,26 @@ const registryLayer = Layer.effect(
|
|||
const planned = yield* Effect.forEach(registrations, ({ tools, options }) =>
|
||||
Effect.gen(function* () {
|
||||
if (options?.namespace !== undefined) yield* validateNamespace(options.namespace)
|
||||
const entries = registrationEntries(tools, options?.namespace)
|
||||
const entries = registrationEntries(tools, options)
|
||||
yield* Effect.forEach(entries, (entry) => validateName(entry.name), { discard: true })
|
||||
const collision = entries.find(
|
||||
(entry, index) => entries.findIndex((candidate) => candidate.key === entry.key) !== index,
|
||||
)
|
||||
if (collision)
|
||||
return yield* Effect.fail(
|
||||
new Tool.RegistrationError({
|
||||
name: collision.key,
|
||||
message: `Duplicate normalized tool name: ${collision.key}`,
|
||||
}),
|
||||
)
|
||||
const codemode = options?.codemode ?? true
|
||||
const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute")
|
||||
if (reserved)
|
||||
return yield* Effect.fail(
|
||||
new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }),
|
||||
new Tool.RegistrationError({
|
||||
name: reserved.key,
|
||||
message: 'Tool name "execute" is reserved for CodeMode',
|
||||
}),
|
||||
)
|
||||
return { tools, options, entries, codemode }
|
||||
}),
|
||||
|
|
@ -218,7 +257,7 @@ const registryLayer = Layer.effect(
|
|||
// CodeMode registrations live in the CodeMode service; the registry keeps only direct tools.
|
||||
yield* Effect.forEach(
|
||||
planned.filter((plan) => plan.codemode && plan.entries.length > 0),
|
||||
(plan) => codeMode.register(plan.tools, plan.options),
|
||||
(plan) => codeMode.register(plan.entries),
|
||||
{ discard: true },
|
||||
)
|
||||
const direct = planned.filter((plan) => !plan.codemode)
|
||||
|
|
@ -237,6 +276,7 @@ const registryLayer = Layer.effect(
|
|||
tool: entry.tool,
|
||||
name: entry.name,
|
||||
namespace: entry.namespace,
|
||||
permission: entry.permission,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
|
@ -269,7 +309,7 @@ const registryLayer = Layer.effect(
|
|||
]),
|
||||
),
|
||||
registerBatch,
|
||||
materialize: Effect.fn("ToolRegistry.materialize")((permissions) =>
|
||||
snapshot: Effect.fn("ToolRegistry.snapshot")((permissions) =>
|
||||
registrationLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const direct = new Map<string, Registration>()
|
||||
|
|
@ -277,21 +317,21 @@ const registryLayer = Layer.effect(
|
|||
for (const [name, entries] of local) {
|
||||
const registration = entries.at(-1)?.registration
|
||||
if (!registration) continue
|
||||
if (whollyDisabled(permission(registration.tool, name), rules)) continue
|
||||
if (whollyDisabled(registration.permission, rules)) continue
|
||||
direct.set(name, registration)
|
||||
}
|
||||
const execute = (yield* codeMode.materialize(permissions)).tool
|
||||
const codemodeTool = (yield* codeMode.materialize(permissions)).tool
|
||||
return {
|
||||
definitions: [
|
||||
...Array.from(direct, ([name, registration]) => definition(name, registration.tool)),
|
||||
...(execute ? [definition("execute", execute)] : []),
|
||||
...Array.from(direct, ([name, registration]) => toLLMDefinition(name, registration.tool)),
|
||||
...(codemodeTool ? [toLLMDefinition("execute", codemodeTool)] : []),
|
||||
],
|
||||
settle: (input: ExecuteInput) => {
|
||||
if (input.call.name === "execute" && execute) return settleTool(input, execute)
|
||||
execute: (input: ExecuteInput) => {
|
||||
if (input.call.name === "execute" && codemodeTool) return executeTool(input, codemodeTool)
|
||||
const registration = direct.get(input.call.name)
|
||||
if (registration) return settleTool(input, registration.tool)
|
||||
return Effect.succeed({
|
||||
result: { type: "error", value: `Unknown tool: ${input.call.name}` },
|
||||
if (registration) return executeTool(input, registration.tool)
|
||||
return Effect.succeed<ToolOutcome>({
|
||||
status: "error",
|
||||
error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}` },
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ export * as ShellTool from "./shell"
|
|||
import path from "path"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Effect, Fiber, Schedule, Schema, Scope } from "effect"
|
||||
import { Deferred, Effect, Schema, Scope } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { PermissionV2 } from "../permission"
|
||||
|
|
@ -147,19 +147,6 @@ export const Plugin = {
|
|||
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. An optional timeout may be provided in milliseconds (zero: unlimited; foreground default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Background commands default to unlimited. Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`,
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: StructuredOutput,
|
||||
toStructuredOutput: ({ output }) => ({
|
||||
truncated: output.truncated,
|
||||
...(output.exit === undefined ? {} : { exit: output.exit }),
|
||||
...(output.shellID === undefined ? {} : { shellID: output.shellID }),
|
||||
...(output.timeout === undefined ? {} : { timeout: output.timeout }),
|
||||
}),
|
||||
toModelOutput: ({ output }) => {
|
||||
const parts: Content[] = [{ type: "text", text: output.output }]
|
||||
const model = modelOutput(output)
|
||||
if (model) parts.push({ type: "text", text: model })
|
||||
return parts
|
||||
},
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = {
|
||||
|
|
@ -199,6 +186,7 @@ export const Plugin = {
|
|||
timeout,
|
||||
metadata: { sessionID: context.sessionID },
|
||||
})
|
||||
yield* context.progress({ shellID: info.id })
|
||||
|
||||
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
|
||||
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
|
||||
|
|
@ -232,7 +220,9 @@ export const Plugin = {
|
|||
}
|
||||
})
|
||||
|
||||
const settled = yield* Deferred.make<Output>()
|
||||
const run = settleShell().pipe(
|
||||
Effect.tap((output) => Deferred.succeed(settled, output)),
|
||||
Effect.map((output) => output.output),
|
||||
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
|
||||
)
|
||||
|
|
@ -256,32 +246,8 @@ export const Plugin = {
|
|||
}
|
||||
}
|
||||
|
||||
let previousProgress: { readonly output: string; readonly truncated: boolean } | undefined
|
||||
const progress = yield* Effect.sleep("1 second").pipe(
|
||||
Effect.andThen(
|
||||
captureShell().pipe(
|
||||
Effect.flatMap((capture) =>
|
||||
Effect.gen(function* () {
|
||||
if (
|
||||
previousProgress?.output === capture.output &&
|
||||
previousProgress.truncated === capture.truncated
|
||||
)
|
||||
return
|
||||
previousProgress = capture
|
||||
yield* context.progress({
|
||||
structured: { truncated: capture.truncated },
|
||||
content: [{ type: "text", text: capture.output }],
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.repeat(Schedule.forever),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe(
|
||||
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)),
|
||||
Effect.ensuring(Fiber.interrupt(progress)),
|
||||
)
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* shell.timeout(info.id, 0)
|
||||
|
|
@ -298,11 +264,23 @@ export const Plugin = {
|
|||
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
|
||||
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
|
||||
|
||||
return {
|
||||
...(yield* settleShell()),
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
return { ...(yield* Deferred.await(settled)), ...(warnings.length ? { warnings } : {}) }
|
||||
}).pipe(
|
||||
Effect.map((output) => {
|
||||
const content: [Content, ...Content[]] = [{ type: "text", text: output.output }]
|
||||
const model = modelOutput(output)
|
||||
if (model) content.push({ type: "text", text: model })
|
||||
return {
|
||||
output,
|
||||
content,
|
||||
metadata: {
|
||||
truncated: output.truncated,
|
||||
...("exit" in output && output.exit !== undefined ? { exit: output.exit } : {}),
|
||||
...("shellID" in output && output.shellID !== undefined ? { shellID: output.shellID } : {}),
|
||||
...("timeout" in output && output.timeout !== undefined ? { timeout: output.timeout } : {}),
|
||||
},
|
||||
}
|
||||
}),
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -21,11 +21,6 @@ export const Output = Schema.Struct({
|
|||
directory: Schema.String,
|
||||
output: Schema.String,
|
||||
})
|
||||
const StructuredOutput = Schema.Struct({
|
||||
name: Output.fields.name,
|
||||
directory: Output.fields.directory,
|
||||
})
|
||||
|
||||
export const description = [
|
||||
"Load a specialized skill when the task at hand matches one of the available skills in the instructions.",
|
||||
"",
|
||||
|
|
@ -70,9 +65,6 @@ export const Plugin = {
|
|||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: StructuredOutput,
|
||||
toStructuredOutput: ({ output }) => ({ name: output.name, directory: output.directory }),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* skills.list()
|
||||
|
|
@ -101,7 +93,13 @@ export const Plugin = {
|
|||
output: toModelOutput(skill, files),
|
||||
}
|
||||
}).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
|
||||
}),
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: output.output,
|
||||
metadata: { name: output.name, directory: output.directory },
|
||||
})),
|
||||
),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
|
|
|
|||
|
|
@ -31,11 +31,6 @@ export const Output = Schema.Struct({
|
|||
status: Schema.Literals(["completed", "running"]),
|
||||
output: Schema.String,
|
||||
})
|
||||
const StructuredOutput = Schema.Struct({
|
||||
sessionID: Output.fields.sessionID,
|
||||
status: Output.fields.status,
|
||||
})
|
||||
|
||||
export const description = [
|
||||
"Spawn a subagent: a child session running a configured agent with fresh context.",
|
||||
"Foreground (default) runs the subagent to completion and returns its final response.",
|
||||
|
|
@ -119,9 +114,6 @@ export const Plugin = {
|
|||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: StructuredOutput,
|
||||
toStructuredOutput: ({ output }) => ({ sessionID: output.sessionID, status: output.status }),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const parent = yield* runtime.session
|
||||
|
|
@ -186,7 +178,7 @@ export const Plugin = {
|
|||
|
||||
const background = input.background === true
|
||||
yield* context.progress({
|
||||
structured: { sessionID: child.id, status: "running" },
|
||||
metadata: { sessionID: child.id, status: "running" },
|
||||
})
|
||||
|
||||
const run = Effect.gen(function* () {
|
||||
|
|
@ -238,7 +230,13 @@ export const Plugin = {
|
|||
if (result?.info.status === "cancelled")
|
||||
return yield* new ToolFailure({ message: "Subagent cancelled" })
|
||||
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
|
||||
}),
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: output.output,
|
||||
metadata: { sessionID: output.sessionID, status: output.status },
|
||||
})),
|
||||
),
|
||||
}),
|
||||
{ codemode: false },
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,2 +1,90 @@
|
|||
export * as Tool from "@opencode-ai/plugin/v2/effect/tool"
|
||||
export * as Tool from "./tool"
|
||||
export * from "@opencode-ai/plugin/v2/effect/tool"
|
||||
|
||||
import type { ToolContent } from "@opencode-ai/ai"
|
||||
import {
|
||||
decodeInput,
|
||||
encodeOutput,
|
||||
type Any,
|
||||
type Content,
|
||||
type Context,
|
||||
Failure,
|
||||
type Metadata,
|
||||
} from "@opencode-ai/plugin/v2/effect/tool"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
/** Non-empty canonical model content. */
|
||||
export type NonEmptyContent = readonly [ToolContent, ...ToolContent[]]
|
||||
|
||||
/**
|
||||
* The execution-local result of one tool call: the machine output for
|
||||
* Code Mode, canonical model content, and optional UI metadata. The typed
|
||||
* domain output never leaves this function.
|
||||
*/
|
||||
export type Execution = {
|
||||
readonly output?: unknown
|
||||
readonly content: NonEmptyContent
|
||||
readonly metadata?: Metadata
|
||||
}
|
||||
|
||||
export const execute = (tool: Any, input: unknown, context: Context): Effect.Effect<Execution, Failure> =>
|
||||
Effect.gen(function* () {
|
||||
const decoded = yield* decodeInput(tool.input, input)
|
||||
const result = yield* tool.execute(decoded, context)
|
||||
if (tool.output === undefined) {
|
||||
if ("output" in result) return yield* Effect.die("Tool result declared output without an output schema")
|
||||
return {
|
||||
content: contentFrom(result.content),
|
||||
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
|
||||
}
|
||||
}
|
||||
if (!("output" in result))
|
||||
return yield* Effect.fail(new Failure({ message: "Tool did not return its declared output" }))
|
||||
const encoded = yield* encodeOutput(tool.output, result.output)
|
||||
return {
|
||||
output: encoded,
|
||||
content: contentFrom(result.content, encoded),
|
||||
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
|
||||
}
|
||||
})
|
||||
|
||||
/** Model content from the tool's projection, falling back to the stringified encoded output. */
|
||||
const contentFrom = (projected: string | ReadonlyArray<Content> | undefined, encoded?: unknown): NonEmptyContent => {
|
||||
if (typeof projected === "string") return [textContent(projected)]
|
||||
if (projected !== undefined) {
|
||||
const mapped = nonEmpty(projected.map(toModelContent))
|
||||
if (mapped !== undefined) return mapped
|
||||
}
|
||||
return [textContent(stringify(encoded))]
|
||||
}
|
||||
|
||||
export const toModelContent = (part: Content): ToolContent =>
|
||||
part.type === "text"
|
||||
? { type: "text", text: part.text }
|
||||
: { type: "file", uri: `data:${part.mime};base64,${part.data}`, mime: part.mime, name: part.name }
|
||||
|
||||
export const nonEmpty = (content: ReadonlyArray<ToolContent>): NonEmptyContent | undefined =>
|
||||
content.length > 0 ? (content as NonEmptyContent) : undefined
|
||||
|
||||
const textContent = (text: string): ToolContent => ({ type: "text", text })
|
||||
|
||||
/** Human-readable text for an arbitrary value; strings pass through unchanged. */
|
||||
export const stringify = (value: unknown) => {
|
||||
if (typeof value === "string") return value
|
||||
try {
|
||||
return JSON.stringify(value) ?? String(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
const MetadataSchema = Schema.Record(Schema.String, Schema.Json)
|
||||
|
||||
/** Defensive boundary: non-JSON or oversized metadata is dropped, never failing the producing call. */
|
||||
export const jsonMetadata = (value: unknown, maxBytes?: number): Metadata | undefined => {
|
||||
if (value === undefined) return undefined
|
||||
const decoded = Schema.decodeUnknownOption(MetadataSchema)(value)
|
||||
if (decoded._tag === "None") return undefined
|
||||
if (maxBytes !== undefined && Buffer.byteLength(JSON.stringify(decoded.value), "utf-8") > maxBytes) return undefined
|
||||
return decoded.value
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@ export type RegisterOptions = Tool.RegisterOptions
|
|||
|
||||
export interface Interface {
|
||||
readonly register: (
|
||||
tools: Readonly<Record<string, Tool.AnyTool>>,
|
||||
tools: Readonly<Record<string, Tool.Any>>,
|
||||
options?: Tool.RegisterOptions,
|
||||
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
|
||||
/** Internal atomic registration capability used by plugin transforms. */
|
||||
readonly registerBatch: (
|
||||
registrations: ReadonlyArray<{
|
||||
readonly tools: Readonly<Record<string, Tool.AnyTool>>
|
||||
readonly tools: Readonly<Record<string, Tool.Any>>
|
||||
readonly options?: Tool.RegisterOptions
|
||||
}>,
|
||||
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
|
||||
|
|
|
|||
|
|
@ -37,10 +37,6 @@ const Output = Schema.Struct({
|
|||
format: Input.fields.format,
|
||||
output: Schema.String,
|
||||
})
|
||||
const StructuredOutput = Schema.Struct({
|
||||
contentType: Output.fields.contentType,
|
||||
})
|
||||
|
||||
type Format = (typeof Input.Type)["format"]
|
||||
|
||||
const acceptHeader = (format: Format) => {
|
||||
|
|
@ -129,9 +125,6 @@ export const Plugin = {
|
|||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: StructuredOutput,
|
||||
toStructuredOutput: ({ output }) => ({ contentType: output.contentType }),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.try({
|
||||
|
|
@ -171,12 +164,13 @@ export const Plugin = {
|
|||
try: () => convert(content, contentType, input.format),
|
||||
catch: (error) => error,
|
||||
})
|
||||
return {
|
||||
const result = {
|
||||
url: input.url,
|
||||
contentType,
|
||||
format: input.format,
|
||||
output,
|
||||
}
|
||||
return { output: result, content: result.output, metadata: { contentType: result.contentType } }
|
||||
}).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }))),
|
||||
}),
|
||||
{ codemode: false },
|
||||
|
|
|
|||
|
|
@ -190,10 +190,6 @@ const Output = Schema.Struct({
|
|||
provider: Provider,
|
||||
text: Schema.String,
|
||||
})
|
||||
const StructuredOutput = Schema.Struct({
|
||||
provider: Output.fields.provider,
|
||||
})
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.websearch",
|
||||
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
|
|
@ -209,9 +205,6 @@ export const Plugin = {
|
|||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: StructuredOutput,
|
||||
toStructuredOutput: ({ output }) => ({ provider: output.provider }),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
|
||||
execute: (input, context) => {
|
||||
const provider = selectProvider(context.sessionID, config, config.provider)
|
||||
return Effect.gen(function* () {
|
||||
|
|
@ -250,10 +243,11 @@ export const Plugin = {
|
|||
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
|
||||
},
|
||||
)
|
||||
return {
|
||||
const output = {
|
||||
provider,
|
||||
text: text ?? NO_RESULTS,
|
||||
}
|
||||
return { output, content: output.text, metadata: { provider: output.provider } }
|
||||
}).pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
|
||||
|
|
|
|||
|
|
@ -56,13 +56,11 @@ export const Plugin = {
|
|||
.transform((draft) =>
|
||||
draft.add(
|
||||
name,
|
||||
Tool.withPermission(
|
||||
Tool.make({
|
||||
Tool.make({
|
||||
description:
|
||||
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = {
|
||||
|
|
@ -110,12 +108,11 @@ export const Plugin = {
|
|||
],
|
||||
} satisfies Output
|
||||
}).pipe(
|
||||
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
||||
),
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
{ codemode: false },
|
||||
{ codemode: false, permission: "edit" },
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
|
|
|||
|
|
@ -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 unknown as unknown[]).splice(0)
|
||||
}),
|
||||
)
|
||||
.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" }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
282
packages/core/test/provider-mistral.test.ts
Normal file
282
packages/core/test/provider-mistral.test.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
import { createMistral } from "@ai-sdk/mistral"
|
||||
import { expect, test } from "bun:test"
|
||||
|
||||
test("Mistral sends promptCacheKey as prompt_cache_key", async () => {
|
||||
let body: Record<string, unknown> | undefined
|
||||
const mockFetch = Object.assign(
|
||||
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
body = JSON.parse(String(init?.body))
|
||||
return Response.json({
|
||||
id: "response-1",
|
||||
created: 0,
|
||||
model: "mistral-large-latest",
|
||||
object: "chat.completion",
|
||||
choices: [{ index: 0, message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
})
|
||||
},
|
||||
{ preconnect: fetch.preconnect },
|
||||
)
|
||||
const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-large-latest")
|
||||
|
||||
await model.doGenerate({
|
||||
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
|
||||
providerOptions: { mistral: { promptCacheKey: "session-123" } },
|
||||
})
|
||||
|
||||
expect(body?.prompt_cache_key).toBe("session-123")
|
||||
})
|
||||
|
||||
test("Mistral round-trips native reasoning in assistant history", async () => {
|
||||
let body: { messages?: unknown[] } | undefined
|
||||
const mockFetch = Object.assign(
|
||||
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
body = JSON.parse(String(init?.body))
|
||||
return Response.json({
|
||||
id: "response-1",
|
||||
created: 0,
|
||||
model: "mistral-small-latest",
|
||||
object: "chat.completion",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: [
|
||||
{ type: "text", text: "The user is greeting me." },
|
||||
{
|
||||
type: "tool_reference",
|
||||
tool: "web_search",
|
||||
title: "Example result",
|
||||
url: "https://example.com/tool",
|
||||
favicon: "https://example.com/favicon.ico",
|
||||
description: "Example description",
|
||||
},
|
||||
{ type: "reference", reference_ids: [1, "source-2"] },
|
||||
],
|
||||
closed: true,
|
||||
signature: "sig-123",
|
||||
},
|
||||
{ type: "text", text: "Hi" },
|
||||
],
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
})
|
||||
},
|
||||
{ preconnect: fetch.preconnect },
|
||||
)
|
||||
const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest")
|
||||
|
||||
const first = await model.doGenerate({
|
||||
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
|
||||
})
|
||||
const reasoning = first.content.find((part) => part.type === "reasoning")
|
||||
const text = first.content.find((part) => part.type === "text")
|
||||
if (!reasoning || !text) throw new Error("expected reasoning and text")
|
||||
|
||||
await model.doGenerate({
|
||||
prompt: [
|
||||
{ role: "user", content: [{ type: "text", text: "Hello" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ ...reasoning, providerOptions: reasoning.providerMetadata }, text],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "Hello again" }] },
|
||||
],
|
||||
})
|
||||
|
||||
expect(body?.messages?.[1]).toEqual({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: [
|
||||
{ type: "text", text: "The user is greeting me." },
|
||||
{
|
||||
type: "tool_reference",
|
||||
tool: "web_search",
|
||||
title: "Example result",
|
||||
url: "https://example.com/tool",
|
||||
favicon: "https://example.com/favicon.ico",
|
||||
description: "Example description",
|
||||
},
|
||||
{ type: "reference", reference_ids: [1, "source-2"] },
|
||||
],
|
||||
closed: true,
|
||||
signature: "sig-123",
|
||||
},
|
||||
{ type: "text", text: "Hi" },
|
||||
],
|
||||
})
|
||||
|
||||
await model.doGenerate({
|
||||
prompt: [
|
||||
{ role: "user", content: [{ type: "text", text: "Hello" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "reasoning", text: "thinking" },
|
||||
{ type: "text", text: "Hi" },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "Hello again" }] },
|
||||
],
|
||||
})
|
||||
expect(body?.messages?.[1]).toEqual({ role: "assistant", content: "thinkingHi" })
|
||||
})
|
||||
|
||||
test("Mistral preserves native reasoning metadata while streaming", async () => {
|
||||
const chunks = [
|
||||
{
|
||||
id: "response-1",
|
||||
created: 0,
|
||||
model: "mistral-small-latest",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: [
|
||||
{ type: "text", text: "thinking" },
|
||||
{
|
||||
type: "tool_reference",
|
||||
tool: "web_search",
|
||||
title: "Example result",
|
||||
url: "https://example.com/tool",
|
||||
favicon: "https://example.com/favicon.ico",
|
||||
description: "Example description",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "response-1",
|
||||
created: 0,
|
||||
model: "mistral-small-latest",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: [{ type: "reference", reference_ids: [1, "source-2"] }],
|
||||
closed: true,
|
||||
signature: "sig-123",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "response-1",
|
||||
created: 0,
|
||||
model: "mistral-small-latest",
|
||||
choices: [{ index: 0, delta: { content: [{ type: "text", text: "answer" }] } }],
|
||||
},
|
||||
{
|
||||
id: "response-1",
|
||||
created: 0,
|
||||
model: "mistral-small-latest",
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
},
|
||||
]
|
||||
const mockFetch = Object.assign(
|
||||
async () =>
|
||||
new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join(""), {
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
}),
|
||||
{ preconnect: fetch.preconnect },
|
||||
)
|
||||
const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest")
|
||||
const result = await model.doStream({
|
||||
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
|
||||
})
|
||||
const events = []
|
||||
for await (const event of result.stream) events.push(event)
|
||||
|
||||
expect(events.find((event) => event.type === "reasoning-end")?.providerMetadata).toEqual({
|
||||
mistral: {
|
||||
thinking: {
|
||||
type: "thinking",
|
||||
thinking: [
|
||||
{ type: "text", text: "thinking" },
|
||||
{
|
||||
type: "tool_reference",
|
||||
tool: "web_search",
|
||||
title: "Example result",
|
||||
url: "https://example.com/tool",
|
||||
favicon: "https://example.com/favicon.ico",
|
||||
description: "Example description",
|
||||
},
|
||||
{ type: "reference", reference_ids: [1, "source-2"] },
|
||||
],
|
||||
closed: true,
|
||||
signature: "sig-123",
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(
|
||||
events
|
||||
.filter((event) => event.type === "reasoning-start" || event.type === "reasoning-delta")
|
||||
.every((event) => event.providerMetadata === undefined),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("Mistral preserves metadata-only thinking chunks", async () => {
|
||||
const thinking = {
|
||||
type: "thinking" as const,
|
||||
thinking: [
|
||||
{
|
||||
type: "tool_reference",
|
||||
tool: "web_search",
|
||||
title: "Example result",
|
||||
url: "https://example.com/tool",
|
||||
favicon: "https://example.com/favicon.ico",
|
||||
description: "Example description",
|
||||
},
|
||||
{ type: "reference", reference_ids: [1, "source-2"] },
|
||||
],
|
||||
closed: true,
|
||||
signature: "sig-123",
|
||||
}
|
||||
const mockFetch = Object.assign(
|
||||
async () =>
|
||||
Response.json({
|
||||
id: "response-1",
|
||||
created: 0,
|
||||
model: "mistral-small-latest",
|
||||
object: "chat.completion",
|
||||
choices: [{ index: 0, message: { role: "assistant", content: [thinking] }, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
}),
|
||||
{ preconnect: fetch.preconnect },
|
||||
)
|
||||
const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest")
|
||||
const result = await model.doGenerate({
|
||||
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
|
||||
})
|
||||
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
providerMetadata: { mistral: { thinking } },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
|
@ -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")),
|
||||
|
|
@ -301,7 +301,7 @@ it.effect("generates from fresh settled Session context without durable mutation
|
|||
),
|
||||
).toEqual(["Settled partial answer"])
|
||||
expect(requests[0]?.tools).toMatchObject([{ name: "lookup", description: "Hooked lookup" }])
|
||||
expect(requests[0]?.toolChoice).toMatchObject({ type: "none" })
|
||||
expect(requests[0]?.toolChoice).toBeUndefined()
|
||||
expect(yield* durableState(db, sessionID)).toEqual(before)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,51 @@ 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" }],
|
||||
}),
|
||||
)
|
||||
const exit = await Effect.runPromiseExit(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" }],
|
||||
})
|
||||
})
|
||||
|
||||
test("failure snapshot retains canonical progress above the default byte limit", async () => {
|
||||
const { published, publisher } = capture("anthropic", { interruptProgress: true })
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
const detail = "x".repeat(60 * 1024)
|
||||
await Effect.runPromiseExit(publisher.progress(call.id, { detail }))
|
||||
await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" }))
|
||||
|
||||
expect(published.find((event) => event.type === "session.tool.failed.2")?.data).toMatchObject({
|
||||
metadata: { detail },
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -119,7 +144,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 +217,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 +226,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,26 +119,24 @@ 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).toMatchObject({
|
||||
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,
|
||||
files: [
|
||||
{
|
||||
file: "src/new.txt",
|
||||
status: "added",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
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,
|
||||
files: [
|
||||
{
|
||||
file: "src/new.txt",
|
||||
status: "added",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
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",
|
||||
|
|
@ -159,12 +157,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({
|
||||
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,
|
||||
files: [
|
||||
|
|
@ -176,9 +176,9 @@ describe("WriteTool", () => {
|
|||
},
|
||||
],
|
||||
})
|
||||
const structured = settled.output?.structured as WriteTool.Output
|
||||
expect(structured.files[0]?.patch).toContain("-before")
|
||||
expect(structured.files[0]?.patch).toContain("+after")
|
||||
const output = settled.output as WriteTool.Output
|
||||
expect(output.files[0]?.patch).toContain("-before")
|
||||
expect(output.files[0]?.patch).toContain("+after")
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe(
|
||||
"after",
|
||||
)
|
||||
|
|
@ -204,8 +204,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"),
|
||||
)
|
||||
|
|
@ -230,7 +230,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")
|
||||
}),
|
||||
|
|
@ -258,7 +261,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"])
|
||||
}),
|
||||
|
|
@ -281,7 +284,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* () {
|
||||
|
|
@ -293,10 +296,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])
|
||||
|
|
@ -358,8 +364,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([])
|
||||
|
|
@ -371,8 +377,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