From bd4fbf9a66f1fb821630c329de89f188a0718e04 Mon Sep 17 00:00:00 2001 From: Ilya Martynov Date: Tue, 8 Sep 2026 16:45:03 +0000 Subject: [PATCH] Invoke selected Pi skills through native commands --- .../src/bridge/bridge.skill-command.test.ts | 235 ++++++++++++++++++ plugins/provider-pi/src/bridge/bridge.ts | 44 +++- .../provider-pi/src/bridge/fake-pi-rpc.mjs | 4 + 3 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 plugins/provider-pi/src/bridge/bridge.skill-command.test.ts diff --git a/plugins/provider-pi/src/bridge/bridge.skill-command.test.ts b/plugins/provider-pi/src/bridge/bridge.skill-command.test.ts new file mode 100644 index 0000000000..75a4095e0a --- /dev/null +++ b/plugins/provider-pi/src/bridge/bridge.skill-command.test.ts @@ -0,0 +1,235 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { JsonValue } from "@get-bb/plugin-sdk"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { + FULL_PERMISSION_OPTIONS, + type FakePiBridgeHarness, + startFakePiBridge, +} from "./test-support.js"; + +let harness: FakePiBridgeHarness; +let requestId: number; +let threadId: number; + +beforeEach(async () => { + requestId = 0; + threadId = 0; + harness = await startFakePiBridge({ + prefix: "bb-pi-skill-command-", + initialize: true, + }); +}); + +afterEach(async () => { + await harness.teardown(); +}); + +async function promptText(input: JsonValue): Promise { + threadId += 1; + const currentThreadId = `thr_skill_command_${threadId}`; + await harness.startThread(currentThreadId); + + requestId += 1; + const response = await harness.request(requestId, "turn/start", { + threadId: currentThreadId, + providerThreadId: currentThreadId, + clientRequestId: "creq_ab23456789", + input, + options: FULL_PERMISSION_OPTIONS, + }); + + expect(response.error).toBeUndefined(); + await harness.waitForTurnBoundary(currentThreadId); + return harness + .deltasOf(currentThreadId) + .filter((delta) => delta.kind === "item.textDelta") + .map((delta) => String(delta.text)) + .join(""); +} + +function selectedSkillMention( + name: string, + start: number, + overrides: { source?: "command" | "skill"; trigger?: string } = {}, +) { + return { + start, + end: start + name.length + 1, + resource: { + kind: "command" as const, + trigger: overrides.trigger ?? "/", + name, + source: overrides.source ?? ("skill" as const), + origin: "user" as const, + label: name, + argumentHint: null, + }, + }; +} + +it("invokes a selected skill through Pi's native command", async () => { + const output = await promptText([ + { + type: "text", + text: "/inspect src", + mentions: [selectedSkillMention("inspect", 0)], + }, + ]); + + expect(output).toContain("Response to: /skill:inspect src"); +}); + +it("invokes a selected skill without arguments", async () => { + const output = await promptText([ + { + type: "text", + text: "/inspect", + mentions: [selectedSkillMention("inspect", 0)], + }, + ]); + + expect(output).toContain("Response to: /skill:inspect"); +}); + +it("moves a selected skill to Pi's command position and preserves its arguments", async () => { + const output = await promptText([ + { + type: "text", + text: "Please /inspect src", + mentions: [selectedSkillMention("inspect", "Please ".length)], + }, + ]); + + expect(output).toContain("Response to: /skill:inspect Please src"); +}); + +it("preserves argument boundary whitespace", async () => { + const output = await promptText([ + { + type: "text", + text: " before /inspect after ", + mentions: [selectedSkillMention("inspect", " before ".length)], + }, + ]); + + expect(output).toContain("Response to: /skill:inspect before after "); +}); + +it("preserves text chunks, local files, and local images in an invocation turn", async () => { + const imagePath = join(harness.workspaceDir, "screenshot.png"); + const filePath = join(harness.workspaceDir, "context.txt"); + const promptDumpPath = join(harness.workspaceDir, "prompt.json"); + writeFileSync(imagePath, Buffer.from("fake png data")); + writeFileSync(filePath, "context"); + vi.stubEnv("FAKE_PI_PROMPT_DUMP", promptDumpPath); + const output = await promptText([ + { type: "text", text: "Before", mentions: [] }, + { type: "localFile", path: filePath }, + { + type: "text", + text: "/inspect after", + mentions: [selectedSkillMention("inspect", 0)], + }, + { type: "localImage", path: imagePath }, + ]); + + expect(output).toContain( + `Response to: /skill:inspect Before\n[Attached file: ${filePath}]\n after`, + ); + const prompt = JSON.parse(readFileSync(promptDumpPath, "utf8")) as { + images?: { data: string; mimeType: string; type: string }[]; + }; + expect(prompt.images).toEqual([ + { + data: Buffer.from("fake png data").toString("base64"), + mimeType: "image/png", + type: "image", + }, + ]); +}); + +it("keeps multiple selected skills unchanged", async () => { + const output = await promptText([ + { + type: "text", + text: "/inspect then /review", + mentions: [ + selectedSkillMention("inspect", 0), + selectedSkillMention("review", "/inspect then ".length), + ], + }, + ]); + + expect(output).toContain("Response to: /inspect then /review"); +}); + +it.each([ + { + name: "empty", + mention: { ...selectedSkillMention("inspect", 0), end: 0 }, + }, + { + name: "out-of-bounds", + mention: { ...selectedSkillMention("inspect", 0), end: 999 }, + }, + { + name: "text mismatch", + mention: selectedSkillMention("other", 0), + }, +])("keeps a $name skill mention unchanged", async ({ mention }) => { + const output = await promptText([ + { type: "text", text: "/inspect src", mentions: [mention] }, + ]); + + expect(output).toContain("Response to: /inspect src"); +}); + +it("rejects negative mention ranges and non-slash triggers at the protocol boundary", async () => { + const currentThreadId = "thr_invalid_skill_command"; + await harness.startThread(currentThreadId); + const invalidInputs = [ + { + type: "text", + text: "/inspect", + mentions: [{ ...selectedSkillMention("inspect", 0), start: -1 }], + }, + { + type: "text", + text: "$inspect", + mentions: [ + { + ...selectedSkillMention("inspect", 0, { trigger: "$" }), + }, + ], + }, + ]; + + for (const input of invalidInputs) { + requestId += 1; + const response = await harness.request(requestId, "turn/start", { + threadId: currentThreadId, + providerThreadId: currentThreadId, + clientRequestId: "creq_ab23456789", + input: [input], + options: FULL_PERMISSION_OPTIONS, + }); + expect(response.error).toBeDefined(); + } +}); + +it("keeps selected provider commands and unselected slash text unchanged", async () => { + const providerCommand = await promptText([ + { + type: "text", + text: "/inspect src", + mentions: [selectedSkillMention("inspect", 0, { source: "command" })], + }, + ]); + const rawText = await promptText([ + { type: "text", text: "/inspect src", mentions: [] }, + ]); + + expect(providerCommand).toContain("Response to: /inspect src"); + expect(rawText).toContain("Response to: /inspect src"); +}); diff --git a/plugins/provider-pi/src/bridge/bridge.ts b/plugins/provider-pi/src/bridge/bridge.ts index aa2a2f8402..1fef5f4a7a 100644 --- a/plugins/provider-pi/src/bridge/bridge.ts +++ b/plugins/provider-pi/src/bridge/bridge.ts @@ -1161,9 +1161,17 @@ interface ExtractedInput { images: ImageContent[]; } +interface SelectedPiSkill { + chunkIndex: number; + end: number; + name: string; + start: number; +} + function extractInput(input: TurnStartParams["input"]): ExtractedInput { const chunks: string[] = []; const images: ImageContent[] = []; + const skills: SelectedPiSkill[] = []; for (const item of input) { if (!item || typeof item !== "object") continue; const typed = item as { @@ -1173,7 +1181,27 @@ function extractInput(input: TurnStartParams["input"]): ExtractedInput { mimeType?: string; }; if (typed.type === "text" && typeof typed.text === "string") { - chunks.push(typed.text); + const chunkIndex = chunks.push(typed.text) - 1; + for (const mention of item.type === "text" ? item.mentions : []) { + const resource = mention.resource; + if ( + resource.kind === "command" && + resource.source === "skill" && + resource.trigger === "/" && + mention.start >= 0 && + mention.start < mention.end && + mention.end <= typed.text.length && + typed.text.slice(mention.start, mention.end) === + `${resource.trigger}${resource.name}` + ) { + skills.push({ + chunkIndex, + end: mention.end, + name: resource.name, + start: mention.start, + }); + } + } } else if (typed.type === "localImage" && typeof typed.path === "string") { try { const data = readFileSync(typed.path).toString("base64"); @@ -1184,6 +1212,20 @@ function extractInput(input: TurnStartParams["input"]): ExtractedInput { chunks.push(`[Attached file: ${typed.path}]`); } } + const [skill] = skills; + if (skills.length === 1 && skill) { + const chunk = chunks[skill.chunkIndex]; + if (chunk !== undefined) { + chunks[skill.chunkIndex] = + `${chunk.slice(0, skill.start)}${chunk.slice(skill.end)}`; + const argumentsText = chunks.join("\n"); + const separator = argumentsText.startsWith(" ") ? "" : " "; + return { + text: `/skill:${skill.name}${argumentsText ? `${separator}${argumentsText}` : ""}`, + images, + }; + } + } return { text: chunks.length > 0 ? chunks.join("\n") : undefined, images }; } diff --git a/plugins/provider-pi/src/bridge/fake-pi-rpc.mjs b/plugins/provider-pi/src/bridge/fake-pi-rpc.mjs index 3ecc77a3fc..7949ce5a11 100644 --- a/plugins/provider-pi/src/bridge/fake-pi-rpc.mjs +++ b/plugins/provider-pi/src/bridge/fake-pi-rpc.mjs @@ -98,6 +98,7 @@ const sessionFile = args.includes("--no-session") ? undefined : flag("--session" const extensionPath = flag("--extension"); const processLogPath = process.env.FAKE_PI_PROCESS_LOG; const commandLogPath = process.env.FAKE_PI_COMMAND_LOG; +const promptDumpPath = process.env.FAKE_PI_PROMPT_DUMP; if (sessionFile !== undefined) { mkdirSync(dirname(sessionFile), { recursive: true }); if (!existsSync(sessionFile)) { @@ -476,6 +477,9 @@ async function handle(command) { }); return; case "prompt": { + if (promptDumpPath) { + writeFileSync(promptDumpPath, JSON.stringify(command), "utf8"); + } if (isStreaming && command.streamingBehavior === "steer") { // A steer into a live run: pi reports the queue BEFORE it answers the // preflight (recorded order), then hands it to the run (a held run