Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/core/src/codemode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ const layer = Layer.effect(
if (rule?.resource === "*" && rule.effect === "deny") continue
registrations.set(name, registration)
}
if (registrations.size === 0) return {}
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
if (executeRule?.resource === "*" && executeRule.effect === "deny") return {}
return {
Expand Down
26 changes: 14 additions & 12 deletions packages/core/src/codemode/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ Use \`search\` to discover exact paths and signatures for additional tools:
## Available tools`

export function render(catalog: CodeModeCatalog.Summary) {
if (catalog.total === 0) return "No tools are currently available."
if (catalog.total === 0)
return "No Code Mode tools are currently available. Do not call `execute` until a later system update announces available tools."

const tools = catalog.namespaces.flatMap((namespace) => {
const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools`
Expand All @@ -38,12 +39,13 @@ ${tools.join("\n")}`
}

export function update(previous: CodeModeCatalog.Summary, current: CodeModeCatalog.Summary) {
const full = `The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.
const replacement = `The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.

${render(current)}`
if (current.total === 0) return replacement
const previousComplete = previous.shown === previous.total
const currentComplete = current.shown === current.total
if (previousComplete !== currentComplete) return full
if (previousComplete !== currentComplete) return replacement

const diff = Instructions.diffByKey(
previous.namespaces.flatMap((namespace) => namespace.entries),
Expand All @@ -54,15 +56,15 @@ ${render(current)}`
const entriesChanged = diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0

if (!currentComplete) {
if (entriesChanged) return full
if (entriesChanged) return replacement
const namespaces = Instructions.diffByKey(
previous.namespaces,
current.namespaces,
(namespace) => namespace.name,
(before, after) => before.count !== after.count,
)
const changed = namespaces.added.length > 0 || namespaces.removed.length > 0 || namespaces.changed.length > 0
if (!changed) return full
if (!changed) return replacement

const parts = ["The Code Mode tool catalog has changed."]
if (namespaces.added.length > 0) {
Expand All @@ -87,11 +89,11 @@ ${render(current)}`
)
}
const delta = parts.join("\n\n")
if (delta.length < full.length) return delta
return full
if (delta.length < replacement.length) return delta
return replacement
}

if (!entriesChanged) return full
if (!entriesChanged) return replacement
const parts = ["The Code Mode tool catalog has changed."]
if (diff.added.length > 0) {
parts.push(
Expand All @@ -117,19 +119,19 @@ ${render(current)}`
)
}
const delta = parts.join("\n\n")
if (delta.length < full.length) return delta
return full
if (delta.length < replacement.length) return delta
return replacement
}

const key = Instructions.Key.make("core/codemode")
const codec = Schema.toCodecJson(CodeModeCatalog.Summary)

export const make = (entries?: ReadonlyArray<CodeModeCatalog.Entry>): Instructions.Instructions => {
const catalog = CodeModeCatalog.summarize(entries ?? [])
const catalog = entries === undefined ? Instructions.removed : CodeModeCatalog.summarize(entries)
return Instructions.make({
key,
codec,
read: Effect.succeed(catalog.total === 0 ? Instructions.removed : catalog),
read: Effect.succeed(catalog),
render: {
initial: render,
changed: update,
Expand Down
4 changes: 3 additions & 1 deletion packages/core/test/codemode/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,9 @@ describe("CodeModeInstructions.render", () => {
})

test("renders only the no-tools notice for an empty catalog", () => {
expect(render([])).toBe("No tools are currently available.")
expect(render([])).toBe(
"No Code Mode tools are currently available. Do not call `execute` until a later system update announces available tools.",
)
})
})

Expand Down
19 changes: 19 additions & 0 deletions packages/core/test/codemode/instructions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,25 @@ const lookup: CodeModeCatalog.Entry = {
}

describe("CodeModeInstructions", () => {
it.effect("instructs the model not to call execute while the catalog is empty", () =>
Effect.gen(function* () {
const initialized = yield* readInitial(CodeModeInstructions.make([]))
expect(initialized.text).toBe(
"No Code Mode tools are currently available. Do not call `execute` until a later system update announces available tools.",
)

const added = yield* readUpdate(CodeModeInstructions.make([echo]), initialized)
expect(added.text).toContain("New tools are available in addition to those previously listed:")
expect(added.text).toContain(echo.signature)

expect(yield* readUpdate(CodeModeInstructions.make([]), { values: added.values })).toMatchObject({
text:
"The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.\n\n" +
"No Code Mode tools are currently available. Do not call `execute` until a later system update announces available tools.",
})
}),
)

it.effect("renders the initial catalog, semantic deltas, and removal", () =>
Effect.gen(function* () {
const initialized = yield* readInitial(CodeModeInstructions.make([echo]))
Expand Down
16 changes: 16 additions & 0 deletions packages/core/test/lib/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,22 @@ export function waitForTool(
})
}

export function waitForCodeModeTool(
registry: ToolRegistry.Interface,
path: string,
remaining = 1000,
): Effect.Effect<ToolRegistry.ToolSet, Error> {
return Effect.gen(function* () {
const toolSet = yield* registry.snapshot()
if (toolSet.codeModeCatalog?.some((tool) => tool.path === path)) return toolSet
if (remaining === 0) {
return yield* Effect.fail(new Error(`Timed out waiting for Code Mode tool: ${path}`))
}
yield* Effect.promise(() => Bun.sleep(1))
return yield* waitForCodeModeTool(registry, path, remaining - 1)
})
}

/**
* 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
Expand Down
24 changes: 15 additions & 9 deletions packages/core/test/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
import { executeTool, toolDefinitions, toolIdentity, waitForCodeModeTool, waitForTool } from "./lib/tool"

let assertion: Deferred.Deferred<PermissionV2.AssertInput> | undefined
let decision: Effect.Effect<void, PermissionV2.Error> = Effect.void
Expand Down Expand Up @@ -802,10 +802,16 @@ test("serializes concurrent MCP lifecycle operations", async () => {
it.effect("advertises MCP output schemas to Code Mode", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "execute")
const definitions = yield* toolDefinitions(registry)
const execute = definitions.find((tool) => tool.name === "execute")

const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
const execute = toolSet.definitions.find((tool) => tool.name === "execute")

expect(toolSet.definitions.map((tool) => tool.name)).toEqual([
"direct_fail",
"direct_lookup",
"direct_media",
"execute",
])
expect(toolSet.codeModeCatalog?.find((tool) => tool.path === "demo.search")?.signature).toContain("ok: boolean")
expect(execute?.description).not.toContain("tools.demo.search")
}),
)
Expand Down Expand Up @@ -873,9 +879,9 @@ it.effect("waits for permission before calling an MCP tool", () =>
const permission = yield* Deferred.make<void>()
decision = Deferred.await(permission)
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "execute")
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")

const fiber = yield* executeTool(registry, {
const fiber = yield* toolSet.execute({
sessionID: SessionV2.ID.make("ses_mcp_permission"),
...toolIdentity,
call: {
Expand Down Expand Up @@ -912,9 +918,9 @@ it.effect("does not call MCP when permission is blocked", () =>
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
decision = Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "execute")
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")

const execution = yield* executeTool(registry, {
const execution = yield* toolSet.execute({
sessionID: SessionV2.ID.make("ses_mcp_blocked"),
...toolIdentity,
call: {
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/session-runner-recorded.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ describe("SessionRunnerLLM recorded", () => {
yield* agents.transform((draft) =>
draft.update(AgentV2.ID.make("build"), (agent) => {
agent.mode = "primary"
agent.permissions.push({ action: "execute", resource: "*", effect: "deny" })
}),
)
const pluginHost = host({
Expand Down
43 changes: 33 additions & 10 deletions packages/core/test/session-runner-tool-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ describe("ToolRegistry", () => {

expect(error).toBeInstanceOf(Tool.RegistrationError)
expect(error.message).toBe('Invalid tool namespace: "slack..admin"')
expect((yield* service.snapshot()).definitions).toEqual([])
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}),
)

Expand All @@ -102,7 +102,7 @@ describe("ToolRegistry", () => {
.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([])
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}),
)

Expand All @@ -117,7 +117,7 @@ describe("ToolRegistry", () => {
.pipe(Effect.flip)

expect(error).toBeInstanceOf(Tool.RegistrationError)
expect((yield* service.snapshot()).definitions).toEqual([])
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}),
)

Expand Down Expand Up @@ -148,6 +148,20 @@ describe("ToolRegistry", () => {
}),
)

it.effect("keeps execute available without Code Mode tools unless explicitly denied", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service

const available = yield* service.snapshot()
expect(available.definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(available.codeModeCatalog).toEqual([])

const denied = yield* service.snapshot([{ action: "execute", resource: "*", effect: "deny" }])
expect(denied.definitions).toEqual([])
expect(denied.codeModeCatalog).toBeUndefined()
}),
)

it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
Expand All @@ -156,7 +170,12 @@ describe("ToolRegistry", () => {
const names = (permissions: PermissionV2.Ruleset) =>
toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))

expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual(["bash", "edit", "write"])
expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual([
"bash",
"edit",
"write",
"execute",
])
expect(
yield* names([
{ action: "*", resource: "*", effect: "deny" },
Expand All @@ -169,7 +188,11 @@ describe("ToolRegistry", () => {
{ action: "*", resource: "*", effect: "deny" },
]),
).toEqual([])
expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["bash", "question"])
expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual([
"bash",
"question",
"execute",
])
}),
)

Expand All @@ -182,7 +205,7 @@ describe("ToolRegistry", () => {

expect(
(yield* toolDefinitions(service, [{ action: "edit", resource: "*", effect: "deny" }])).map((tool) => tool.name),
).toEqual(["first"])
).toEqual(["first", "execute"])
}),
)

Expand All @@ -191,9 +214,9 @@ describe("ToolRegistry", () => {
const service = yield* ToolRegistry.Service
const scope = yield* Scope.make()
yield* service.register({ echo: make() }, { codemode: false }).pipe(Scope.provide(scope))
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo", "execute"])
yield* Scope.close(scope, Exit.void)
expect(yield* toolDefinitions(service)).toEqual([])
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["execute"])
}),
)

Expand All @@ -213,9 +236,9 @@ describe("ToolRegistry", () => {
yield* Deferred.await(registered)
yield* Fiber.interrupt(fiber)

expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo", "execute"])
yield* Scope.close(scope, Exit.void)
expect(yield* toolDefinitions(service)).toEqual([])
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["execute"])
}),
)

Expand Down
40 changes: 40 additions & 0 deletions packages/core/test/session-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,46 @@ describe("SessionRunnerLLM", () => {
}),
)

it.effect("advertises execute and durable guidance for an empty Code Mode catalog", () =>
Effect.gen(function* () {
const session = yield* setup
const empty = {
catalog: [],
tool: Tool.make({
description: "Execute Code Mode",
input: Schema.Struct({ code: Schema.String }),
output: Schema.String,
execute: () => Effect.succeed({ output: "unused" }),
}),
}
codeModeMaterializations = [empty, empty, {}]
yield* admit(session, "Continue without Code Mode tools")
response = reply.stop()

yield* session.resume(sessionID)
yield* admit(session, "Still no Code Mode tools")
yield* session.resume(sessionID)
yield* admit(session, "Code Mode denied")
yield* session.resume(sessionID)

expect(requests).toHaveLength(3)
expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["defect", "echo", "storefail", "execute"])
expect(requests[0]?.system.some((part) => part.text.includes("Do not call `execute`"))).toBe(true)
expect(requests[1]?.tools.map((tool) => tool.name)).toEqual(["defect", "echo", "storefail", "execute"])
expect(requests[1]?.messages.filter((message) => message.role === "system")).toEqual([])
expect(requests[2]?.tools.map((tool) => tool.name)).toEqual(["defect", "echo", "storefail"])
expect(
requests[2]?.messages.some(
(message) =>
message.role === "system" &&
message.content.some(
(part) => part.type === "text" && part.text.includes("Code Mode tools are no longer available"),
),
),
).toBe(true)
}),
)

it.effect("applies session context hooks without exposing unavailable tools", () =>
Effect.gen(function* () {
const session = yield* setup
Expand Down
10 changes: 6 additions & 4 deletions packages/core/test/tool-edit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,12 @@ describe("EditTool", () => {
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["edit"])
expect(yield* toolDefinitions(registry, [{ action: "edit", resource: "*", effect: "deny" }])).toEqual(
[],
)
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["edit", "execute"])
expect(
(yield* toolDefinitions(registry, [{ action: "edit", resource: "*", effect: "deny" }])).map(
(tool) => tool.name,
),
).toEqual(["execute"])
const settled = yield* executeTool(
registry,
call({ path: "hello.txt", oldString: "before", newString: "after" }),
Expand Down
2 changes: 1 addition & 1 deletion packages/core/test/tool-patch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ describe("PatchTool", () => {
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["patch"])
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["patch", "execute"])
const settled = yield* executeTool(
registry,
call(
Expand Down
Loading
Loading