fix(core): settle unadvertised tool calls and repair session hook test contexts

This commit is contained in:
Kit Langton 2026-07-07 21:51:23 -04:00
commit eb661e6e6d
5 changed files with 30 additions and 5 deletions

View file

@ -315,10 +315,25 @@ const layer = Layer.effect(
}
yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
if (!toolMaterialization || !advertisedTools.has(event.name)) {
if (!toolMaterialization) {
yield* serialized(
publisher.failUnsettledTools({
type: "tool.execution",
message: "Tools are disabled after the maximum agent steps",
}),
)
return
}
// A request hook hid this registered tool from the current request. Fail only
// this call durably and continue so the model can react, instead of executing
// a tool that was not advertised. Unregistered tools flow through settle, which
// durably fails them as unknown.
if (!advertisedTools.has(event.name) && availableTools.has(event.name)) {
needsContinuation = true
yield* publish(
LLMEvent.toolError({
id: event.id,
name: event.name,
message: `Tool is not available for this request: ${event.name}`,
}),
)

View file

@ -6,6 +6,7 @@ import { Tool } from "@opencode-ai/core/tool/tool"
import { Tools } from "@opencode-ai/core/tool/tools"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, type Scope } from "effect"
import { host } from "../plugin/host"
export const toolIdentity = {
agent: AgentV2.ID.make("build"),
@ -48,7 +49,7 @@ export const registerToolPlugin = <R>(plugin: {
}): Effect.Effect<void, never, R | Tools.Service | Scope.Scope> =>
Effect.gen(function* () {
const tools = yield* Tools.Service
const context: Pick<PluginContext, "tool"> = {
const context = host({
tool: {
transform: (callback) =>
Effect.gen(function* () {
@ -71,8 +72,8 @@ export const registerToolPlugin = <R>(plugin: {
}),
hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
},
}
yield* plugin.effect(context as PluginContext)
})
yield* plugin.effect(context)
})
export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) =>

View file

@ -290,6 +290,7 @@ describe("LocationServiceMap", () => {
"edit",
"glob",
"grep",
"patch",
"question",
"read",
"shell",
@ -307,6 +308,7 @@ describe("LocationServiceMap", () => {
"edit",
"glob",
"grep",
"patch",
"question",
"read",
"shell",

View file

@ -83,7 +83,9 @@ export function host(overrides: Overrides = {}): PluginContext {
prompt: () => Effect.die("unused session.prompt"),
command: () => Effect.die("unused session.command"),
interrupt: () => Effect.die("unused session.interrupt"),
hook: () => Effect.die("unused session.hook"),
// Plugins register session hooks during setup, so a bare host accepts the
// registration; the callback only runs when a test triggers the request pipeline.
hook: () => Effect.succeed({ dispose: Effect.void }),
},
}
}

View file

@ -107,6 +107,11 @@ const withSubagent = (location: Location.Ref) =>
const locations = yield* LocationServiceMap.Service
yield* AgentV2.Service.use((agents) =>
agents.transform((draft) => {
// The caller identity used by executeTool; subagent permission asserts against it.
draft.update(toolIdentity.agent, (agent) => {
agent.mode = "primary"
agent.permissions.push({ action: "*", resource: "*", effect: "allow" })
})
draft.update(AgentV2.ID.make("reviewer"), (agent) => {
agent.mode = "subagent"
agent.model = childModel