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..ef691a390c --- /dev/null +++ b/plugins/provider-pi/src/bridge/bridge.skill-command.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, expect, it } from "vitest"; +import { + FULL_PERMISSION_OPTIONS, + type FakePiBridgeHarness, + startFakePiBridge, +} from "./test-support.js"; + +let harness: FakePiBridgeHarness; + +beforeEach(async () => { + harness = await startFakePiBridge({ + prefix: "bb-pi-skill-command-", + initialize: true, + }); +}); + +afterEach(async () => { + await harness.teardown(); +}); + +it("invokes a selected skill through Pi's native command", async () => { + const threadId = "thr_skill_command"; + await harness.startThread(threadId); + + const response = await harness.request(1, "turn/start", { + threadId, + providerThreadId: threadId, + clientRequestId: "creq_ab23456789", + input: [ + { + type: "text", + text: "/inspect src", + mentions: [ + { + start: 0, + end: "/inspect".length, + resource: { + kind: "command", + trigger: "/", + name: "inspect", + source: "skill", + origin: "user", + label: "inspect", + argumentHint: null, + }, + }, + ], + }, + ], + options: FULL_PERMISSION_OPTIONS, + }); + + expect(response.error).toBeUndefined(); + await harness.waitForTurnBoundary(threadId); + const output = harness + .deltasOf(threadId) + .filter((delta) => delta.kind === "item.textDelta") + .map((delta) => String(delta.text)) + .join(""); + expect(output).toContain("Response to: /skill:inspect src"); +}); + +it("keeps selected provider commands in their displayed form", async () => { + const threadId = "thr_provider_command"; + await harness.startThread(threadId); + + const response = await harness.request(2, "turn/start", { + threadId, + providerThreadId: threadId, + clientRequestId: "creq_cd23456789", + input: [ + { + type: "text", + text: "/inspect src", + mentions: [ + { + start: 0, + end: "/inspect".length, + resource: { + kind: "command", + trigger: "/", + name: "inspect", + source: "command", + origin: "user", + label: "inspect", + argumentHint: null, + }, + }, + ], + }, + ], + options: FULL_PERMISSION_OPTIONS, + }); + + expect(response.error).toBeUndefined(); + await harness.waitForTurnBoundary(threadId); + const output = harness + .deltasOf(threadId) + .filter((delta) => delta.kind === "item.textDelta") + .map((delta) => String(delta.text)) + .join(""); + expect(output).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..df33947f4f 100644 --- a/plugins/provider-pi/src/bridge/bridge.ts +++ b/plugins/provider-pi/src/bridge/bridge.ts @@ -1161,27 +1161,45 @@ interface ExtractedInput { images: ImageContent[]; } +type TextPromptInput = Extract< + TurnStartParams["input"][number], + { type: "text" } +>; + +function toPiPromptText(input: TextPromptInput): string { + let text = input.text; + const mentions = [...input.mentions].sort( + (left, right) => right.start - left.start || right.end - left.end, + ); + for (const mention of mentions) { + const resource = mention.resource; + if ( + resource.kind !== "command" || + resource.source !== "skill" || + input.text.slice(mention.start, mention.end) !== + `${resource.trigger}${resource.name}` + ) { + continue; + } + text = `${text.slice(0, mention.start)}${resource.trigger}skill:${resource.name}${text.slice(mention.end)}`; + } + return text; +} + function extractInput(input: TurnStartParams["input"]): ExtractedInput { const chunks: string[] = []; const images: ImageContent[] = []; for (const item of input) { - if (!item || typeof item !== "object") continue; - const typed = item as { - type?: string; - text?: string; - path?: string; - mimeType?: string; - }; - if (typed.type === "text" && typeof typed.text === "string") { - chunks.push(typed.text); - } else if (typed.type === "localImage" && typeof typed.path === "string") { + if (item.type === "text") { + chunks.push(toPiPromptText(item)); + } else if (item.type === "localImage") { try { - const data = readFileSync(typed.path).toString("base64"); - const mimeType = typed.mimeType ?? mimeTypeFromExtension(typed.path); + const data = readFileSync(item.path).toString("base64"); + const mimeType = mimeTypeFromExtension(item.path); images.push({ type: "image", data, mimeType }); } catch {} - } else if (typed.type === "localFile" && typeof typed.path === "string") { - chunks.push(`[Attached file: ${typed.path}]`); + } else if (item.type === "localFile") { + chunks.push(`[Attached file: ${item.path}]`); } } return { text: chunks.length > 0 ? chunks.join("\n") : undefined, images };