Files
opencode/packages/opencode/test/session/tools.test.ts
T
2026-06-29 08:17:53 -05:00

239 lines
9.1 KiB
TypeScript

import { describe, expect } from "bun:test"
import { jsonSchema, tool, type ToolExecutionOptions } from "ai"
import { Effect, Layer } from "effect"
import { ProjectV2 } from "@opencode-ai/core/project"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { Agent } from "@/agent/agent"
import { MCP } from "@/mcp"
import { Permission } from "@/permission"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { SessionTools } from "@/session/tools"
import { MessageID, SessionID } from "@/session/schema"
import { Session } from "@/session/session"
import { Plugin } from "@/plugin"
import { ToolRegistry } from "@/tool/registry"
import { Truncate } from "@/tool/truncate"
import { ProviderTest } from "../fake/provider"
import { testEffect } from "../lib/effect"
const model = ProviderTest.model()
const sessionID = SessionID.make("ses_deferred-tools")
const largeSchemaDescription = "analytics trends schema ".repeat(10_000)
const mcpClient = Object.assign(new Client({ name: "test", version: "0.0.0" }), {
getServerCapabilities: () => ({}),
})
const agent = {
name: "build",
mode: "primary",
permission: Permission.fromConfig({ "*": "allow" }),
options: {},
} satisfies Agent.Info
const session = {
id: sessionID,
slug: "deferred-tools",
projectID: ProjectV2.ID.make("proj_deferred-tools"),
directory: "/tmp/project",
title: "Deferred tools",
version: "0.0.0",
time: { created: 0, updated: 0 },
} satisfies Session.Info
const assistant = {
id: MessageID.make("msg_assistant"),
parentID: MessageID.make("msg_user"),
role: "assistant",
mode: agent.name,
agent: agent.name,
path: { cwd: session.directory, root: session.directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: ModelV2.ID.make(model.id),
providerID: ProviderV2.ID.make(model.providerID),
time: { created: 0 },
sessionID,
} satisfies SessionV1.Assistant
const user = {
id: MessageID.make("msg_user"),
sessionID,
role: "user",
time: { created: 0 },
agent: agent.name,
model: { providerID: ProviderV2.ID.make(model.providerID), modelID: ModelV2.ID.make(model.id) },
} satisfies SessionV1.User
const processor = {
message: assistant,
updateToolCall: () => Effect.succeed(undefined),
completeToolCall: () => Effect.void,
}
const promptOps = {
cancel: () => Effect.void,
resolvePromptParts: () => Effect.succeed([]),
prompt: () => Effect.die(new Error("unexpected task prompt")),
}
const toolExecutionOptions = {
toolCallId: "call_deferred",
abortSignal: new AbortController().signal,
messages: [],
} satisfies ToolExecutionOptions
const makeIt = (input: { flags: Parameters<typeof RuntimeFlags.layer>[0]; queryDescription: string }) =>
testEffect(
Layer.mergeAll(
Layer.mock(MCP.Service, {
clients: () => Effect.succeed({ posthog: mcpClient }),
tools: () =>
Effect.succeed({
posthog_query_trends: tool({
description: "Query product analytics trends and charts",
inputSchema: jsonSchema({
type: "object",
properties: {
query: { type: "string", description: input.queryDescription },
date_range: { type: "string", description: "Date range for the trends query" },
},
required: ["query"],
}),
execute: async () => ({ content: [{ type: "text", text: "trend result" }] }),
}),
posthog_feature_flags: tool({
description: "List and manage feature flags",
inputSchema: jsonSchema({ type: "object", properties: {} }),
execute: async () => ({ content: [{ type: "text", text: "flag result" }] }),
}),
}),
}),
Layer.mock(ToolRegistry.Service, {
tools: () => Effect.succeed([]),
}),
Layer.mock(Permission.Service, {
ask: () => Effect.void,
}),
Layer.mock(Truncate.Service, {
output: (text) => Effect.succeed({ content: text, truncated: false as const }),
}),
Layer.succeed(
Plugin.Service,
Plugin.Service.of({
init: () => Effect.void,
list: () => Effect.succeed([]),
trigger: ((_name, _input, output) => Effect.succeed(output)) as Plugin.Interface["trigger"],
}),
),
RuntimeFlags.layer(input.flags),
),
)
const deferredIt = makeIt({ flags: { experimentalToolSearch: true }, queryDescription: largeSchemaDescription })
const directIt = makeIt({ flags: { experimentalToolSearch: false }, queryDescription: largeSchemaDescription })
const belowThresholdIt = makeIt({
flags: { experimentalToolSearch: true },
queryDescription: "Natural language analytics query",
})
function resolveToolResult(input: { messages?: SessionV1.WithParts[] } = {}) {
return SessionTools.resolve({
agent,
model,
session,
processor,
bypassAgentCheck: false,
messages: input.messages ?? [],
promptOps,
})
}
function resolveTools(input: { messages?: SessionV1.WithParts[] } = {}) {
return resolveToolResult(input).pipe(Effect.map((result) => result.tools))
}
describe("session.tools", () => {
deferredIt.instance("defers MCP tools behind fixed search and call tools", () =>
Effect.gen(function* () {
const tools = yield* resolveTools()
expect(Object.keys(tools).sort()).toEqual(["call_deferred_tool", "search_deferred_tools"])
const search = tools.search_deferred_tools.execute
if (!search) throw new Error("missing search_deferred_tools executor")
const searchResult = yield* Effect.promise(() =>
Promise.resolve(search({ query: "analytics trends" }, toolExecutionOptions)),
)
const parsed = JSON.parse(searchResult.output) as { tools: Array<{ tool_id: string; input_schema?: unknown }> }
expect(parsed.tools.map((item) => item.tool_id)).toContain("posthog_query_trends")
expect(parsed.tools.find((item) => item.tool_id === "posthog_query_trends")?.input_schema).toBeDefined()
const conciseResult = yield* Effect.promise(() =>
Promise.resolve(search({ query: "analytics trends", include_schema: false }, toolExecutionOptions)),
)
const concise = JSON.parse(conciseResult.output) as { tools: Array<{ tool_id: string; input_schema?: unknown }> }
expect(concise.tools.map((item) => item.tool_id)).toContain("posthog_query_trends")
expect(concise.tools.find((item) => item.tool_id === "posthog_query_trends")?.input_schema).toBeUndefined()
const call = tools.call_deferred_tool.execute
if (!call) throw new Error("missing call_deferred_tool executor")
const callResult = yield* Effect.promise(() =>
Promise.resolve(
call({ tool_id: "posthog_query_trends", arguments: { query: "signups over time" } }, toolExecutionOptions),
),
)
expect(callResult.output).toBe("trend result")
}),
)
deferredIt.instance("lists deferred MCP servers in the system prompt", () =>
Effect.gen(function* () {
const prompt = (yield* resolveToolResult()).deferredSystemPrompt
expect(prompt).toContain("Deferred MCP servers available through `search_deferred_tools`:")
expect(prompt).toContain("- posthog: 2 tools")
}),
)
deferredIt.instance("does not expose per-message disabled MCP tools through deferred search", () =>
Effect.gen(function* () {
const tools = yield* resolveTools({
messages: [
{
info: { ...user, tools: { posthog_feature_flags: false } },
parts: [],
},
],
})
const search = tools.search_deferred_tools.execute
if (!search) throw new Error("missing search_deferred_tools executor")
const searchResult = yield* Effect.promise(() =>
Promise.resolve(search({ query: "feature flags" }, toolExecutionOptions)),
)
const parsed = JSON.parse(searchResult.output) as { tools: Array<{ tool_id: string }> }
expect(parsed.tools.map((item) => item.tool_id)).not.toContain("posthog_feature_flags")
const call = tools.call_deferred_tool.execute
if (!call) throw new Error("missing call_deferred_tool executor")
yield* Effect.promise(async () => {
await expect(
Promise.resolve(call({ tool_id: "posthog_feature_flags", arguments: {} }, toolExecutionOptions)),
).rejects.toThrow('Deferred tool "posthog_feature_flags" is not available')
})
}),
)
directIt.instance("keeps MCP tools direct when tool search is disabled", () =>
Effect.gen(function* () {
const tools = yield* resolveTools()
expect(Object.keys(tools).sort()).toEqual(["posthog_feature_flags", "posthog_query_trends"])
}),
)
belowThresholdIt.instance("keeps MCP tools direct below the fixed deferral threshold", () =>
Effect.gen(function* () {
const tools = yield* resolveTools()
expect(Object.keys(tools).sort()).toEqual(["posthog_feature_flags", "posthog_query_trends"])
}),
)
})