From 4d7683557ad4a2411d6de405e500e4a56b43d0e1 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:40:19 +0200 Subject: [PATCH] fix(pi): load T3 tool schemas for completion gateways (#18643) --- .../src/server/mcpBridge.testkit.ts | 5 +- .../src/server/mcpExtensionSource.test.ts | 79 ++++++++++++++----- .../src/server/mcpExtensionSource.ts | 36 ++++++--- 3 files changed, 88 insertions(+), 32 deletions(-) diff --git a/packages/provider-pi/src/server/mcpBridge.testkit.ts b/packages/provider-pi/src/server/mcpBridge.testkit.ts index afaa4888b320a3e0bce8ccc80af4989bb320f8e8..2b9b4701e367bf027ca36da2122502418eacaefb 100644 --- a/packages/provider-pi/src/server/mcpBridge.testkit.ts +++ b/packages/provider-pi/src/server/mcpBridge.testkit.ts @@ -30,7 +30,10 @@ interface RegisteredTool { type AgentStartHook = ( event: { systemPrompt: string }, - ctx: { ui: { notify: (message: string, severity: string) => void } }, + ctx: { + model?: { api: string }; + ui: { notify: (message: string, severity: string) => void }; + }, ) => Promise<{ systemPrompt: string }>; export async function loadMcpBridge( diff --git a/packages/provider-pi/src/server/mcpExtensionSource.test.ts b/packages/provider-pi/src/server/mcpExtensionSource.test.ts index b8857d3c629cb1a9a5aa8f348cf5832bc34f7f8c..8151878afafbfdf4481a7c4157b63d3dfc800118 100644 --- a/packages/provider-pi/src/server/mcpExtensionSource.test.ts +++ b/packages/provider-pi/src/server/mcpExtensionSource.test.ts @@ -111,18 +111,40 @@ describe("Pi MCP tool results", () => { }); describe("Pi MCP tool exposure", () => { + const discoveryContext = { + model: { api: "anthropic-messages" }, + ui: { notify: () => undefined }, + }; + + it.each(["session_start", "session_tree", "model_select", "before_agent_start"])( + "declares optional T3 tools for OpenAI-compatible gateways at %s", + async (hookName) => { + const bridge = await loadMcpBridge({ modern: true, toolSearchAvailable: true }); + const hook = bridge.handlers.get(hookName); + assert.isDefined(hook); + await hook!( + { systemPrompt: "Pi system prompt" }, + { model: { api: "openai-completions" }, ui: { notify: assert.fail } }, + ); + assert.isTrue( + bridge.tools + .filter((tool) => tool.name.startsWith("mcp__t3-code__")) + .every((tool) => bridge.getActiveTools().includes(tool.name)), + ); + const result = await bridge.tools + .find((tool) => tool.name === "mcp__t3-code__preview_snapshot")! + .execute("gateway-call", {}); + assert.equal(result.content[0]?.text, "browser snapshot"); + assert.equal(bridge.tools.filter((tool) => tool.exposure === "hidden").length, 4); + }, + ); + it("keeps orchestration direct and optional bridge tools discoverable on modern Pi", async () => { const bridge = await loadMcpBridge({ modern: true, toolSearchAvailable: true }); - await bridge.handlers.get("session_start")!( - { systemPrompt: "" }, - { ui: { notify: () => undefined } }, - ); + await bridge.handlers.get("session_start")!({ systemPrompt: "" }, discoveryContext); const start = bridge.handlers.get("before_agent_start"); assert.isDefined(start); - const prompt = await start!( - { systemPrompt: "Pi system prompt" }, - { ui: { notify: () => undefined } }, - ); + const prompt = await start!({ systemPrompt: "Pi system prompt" }, discoveryContext); assert.include(prompt.systemPrompt, "orchestrator_capabilities"); assert.equal(bridge.servers.length, 0); assert.equal(bridge.tools.length, 8); @@ -154,6 +176,33 @@ describe("Pi MCP tool exposure", () => { assert.equal(bridge.requests.at(-1)?.method, "tools/call"); }); + it("restores gateway declarations after model switches and older tree loadouts without excluded tools", async () => { + const bridge = await loadMcpBridge({ + modern: true, + toolSearchAvailable: true, + allowsTool: (name) => name !== "mcp__t3-code__delegate_task", + }); + const event = { systemPrompt: "" }; + await bridge.handlers.get("session_start")!(event, discoveryContext); + const gatewayContext = { model: { api: "openai-completions" }, ui: { notify: assert.fail } }; + await bridge.handlers.get("model_select")!(event, gatewayContext); + bridge.restoreActiveTools(["read", "mcp__t3-code__task_status"]); + await bridge.handlers.get("session_tree")!(event, gatewayContext); + assert.deepEqual(bridge.getActiveTools(), [ + "read", + "mcp__t3-code__task_status", + "tool_search", + "mcp__t3-code__orchestrator_capabilities", + "mcp__t3-code__preview_snapshot", + ]); + await bridge.handlers.get("model_select")!(event, discoveryContext); + assert.equal( + bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot")?.exposure, + "deferred", + ); + assert.include(bridge.getActiveTools(), "mcp__t3-code__preview_snapshot"); + }); + it.each(["legacy Pi", "disabled tool search"])( "keeps tool execution available with %s", async (mode) => { @@ -164,7 +213,7 @@ describe("Pi MCP tool exposure", () => { }); if (mode !== "legacy Pi") { const start = bridge.handlers.get("session_start"); - await start!({ systemPrompt: "Pi system prompt" }, { ui: { notify: () => undefined } }); + await start!({ systemPrompt: "Pi system prompt" }, discoveryContext); } assert.equal(bridge.tools.filter((tool) => tool.exposure !== "hidden").length, 4); assert.isTrue( @@ -198,10 +247,7 @@ describe("Pi MCP tool exposure", () => { allowsTool: (name) => name === "read" || name === "tool_search" || name.startsWith("mcp__t3-code__"), }); - await bridge.handlers.get("session_start")!( - { systemPrompt: "" }, - { ui: { notify: () => undefined } }, - ); + await bridge.handlers.get("session_start")!({ systemPrompt: "" }, discoveryContext); assert.equal(bridge.tools.length, 4); assert.equal(bridge.tools.filter((tool) => tool.exposure === "direct").length, 3); const tool = bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot"); @@ -218,10 +264,7 @@ describe("Pi MCP tool exposure", () => { allowsTool: (name) => name !== "mcp__t3-code__delegate_task" && (!excludeSearch || name !== "tool_search"), }); - await bridge.handlers.get("session_start")!( - { systemPrompt: "" }, - { ui: { notify: () => undefined } }, - ); + await bridge.handlers.get("session_start")!({ systemPrompt: "" }, discoveryContext); bridge.restoreActiveTools([ "read", "mcp__t3-code__task_status", @@ -229,7 +272,7 @@ describe("Pi MCP tool exposure", () => { ]); const tree = bridge.handlers.get("session_tree"); assert.isDefined(tree); - await tree!({ systemPrompt: "" }, { ui: { notify: () => undefined } }); + await tree!({ systemPrompt: "" }, discoveryContext); assert.deepEqual(bridge.getActiveTools(), [ "read", "mcp__t3-code__task_status", diff --git a/packages/provider-pi/src/server/mcpExtensionSource.ts b/packages/provider-pi/src/server/mcpExtensionSource.ts index 7071b1ae6209f3d100d9807e45d529d7c6a9c80f..5ee4342e5bb9acffe5472b1f56a632e1e0e2a10c 100644 --- a/packages/provider-pi/src/server/mcpExtensionSource.ts +++ b/packages/provider-pi/src/server/mcpExtensionSource.ts @@ -1,8 +1,9 @@ /** * Source for the T3-owned Pi extension that consumes T3's HTTP MCP server. * - * Pi 0.99+ discovers optional HTTP bridge tools on demand. Older versions - * keep them directly available. Pi loads this TypeScript via `--extension`; the + * Pi 0.99+ discovers optional HTTP bridge tools on demand. OpenAI completion + * gateways need their schemas loaded up front. Older versions keep tools + * directly available. Pi loads this TypeScript via `--extension`; the * server writes it to a cache so packaged builds need no sibling .ts file. * * Do not import t3code modules from the string body. The Pi process resolves @@ -24,7 +25,7 @@ export const T3_PI_MCP_EXTENSION_PATH_ENV = "T3_PI_MCP_EXTENSION_PATH"; export const PI_FILE_CHANGE_TOOLS = ["edit", "write"] as const; export const PI_T3_MCP_EXTENSION_SOURCE = `\ -import { stripFrontmatter, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { stripFrontmatter, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; import { Type } from "typebox"; @@ -421,23 +422,31 @@ export default async function t3McpExtension(pi: ExtensionAPI) { // mcp.json configures it; retry a failed connection at session_start. await ensureStarted().catch(() => undefined); - const reconcileDiscovery = () => { + const reconcileDiscovery = (ctx: Pick) => { if (!supportsExposure) return; // A disabled or replaced search builtin cannot discover deferred tools. const hasToolSearch = hasBuiltinToolSearch(); const exposureChanged = deferOptionalTools !== hasToolSearch; deferOptionalTools = hasToolSearch; if (exposureChanged) registerTools(); - if (hasToolSearch) { - const active = pi.getActiveTools(); - if (!active.includes("tool_search")) pi.setActiveTools([...active, "tool_search"]); - } + const active = pi.getActiveTools(); + // Completion gateways can silently drop calls to undeclared tools named + // in history or guidance. Eagerly load T3 schemas, including after tree + // restores, while retaining deferred exposure for Pi's allowlist rules. + // Pi still applies --tools/--exclude-tools to this active loadout. + const required = [ + ...(hasToolSearch ? ["tool_search"] : []), + ...(ctx.model?.api === "openai-completions" ? catalog.map((tool) => "mcp__t3-code__" + tool.name) : []), + ]; + const missing = required.filter((name) => !active.includes(name)); + if (missing.length > 0) pi.setActiveTools([...active, ...missing]); }; // Tree navigation restores its saved loadout after session_start. - pi.on("session_tree", reconcileDiscovery); + pi.on("session_tree", (_event, ctx) => reconcileDiscovery(ctx)); + pi.on("model_select", (_event, ctx) => reconcileDiscovery(ctx)); pi.on("session_start", async (_event, ctx) => { - reconcileDiscovery(); + reconcileDiscovery(ctx); try { await ensureStarted(); } catch (error) { @@ -449,8 +458,9 @@ export default async function t3McpExtension(pi: ExtensionAPI) { // Deliver orchestration guidance through pi's real system-prompt channel. // Wrapping the first user message instead would stop it from starting // with "/" and silently break slash-command expansion. - pi.on("before_agent_start", (event) => ({ - systemPrompt: event.systemPrompt + "\\n\\n" + ORCHESTRATION_INSTRUCTIONS, - })); + pi.on("before_agent_start", (event, ctx) => { + reconcileDiscovery(ctx); + return { systemPrompt: event.systemPrompt + "\\n\\n" + ORCHESTRATION_INSTRUCTIONS }; + }); } `; -- 2.54.0