diff --git a/packages/coding-agent/src/modes/skill-command.ts b/packages/coding-agent/src/modes/skill-command.ts index 984fa110b..d8feaaaf2 100644 --- a/packages/coding-agent/src/modes/skill-command.ts +++ b/packages/coding-agent/src/modes/skill-command.ts @@ -1,3 +1,4 @@ +import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai"; import { type CustomMessage, SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../session/messages"; import type { InteractiveModeContext } from "./types"; @@ -8,7 +9,7 @@ type SkillPromptMessage = Pick< "customType" | "content" | "display" | "details" | "attribution" > & { customType: typeof SKILL_PROMPT_MESSAGE_TYPE; - content: string; + content: string | (TextContent | ImageContent)[]; display: true; details: SkillPromptDetails; attribution: "user"; @@ -27,6 +28,7 @@ interface ParsedSkillCommand { interface InvokeSkillCommandOptions { propagateErrors?: boolean; queueOnly?: boolean; + images?: ImageContent[]; } /** Built custom-message payload and delivery options for a `/skill:` command. */ @@ -55,6 +57,7 @@ export async function buildSkillCommandPrompt( ctx: SkillCommandHost, text: string, streamingBehavior: "steer" | "followUp", + images?: ImageContent[], ): Promise { const parsed = parseSkillCommand(text); if (!parsed) return undefined; @@ -68,6 +71,8 @@ export async function buildSkillCommandPrompt( metaLines.push(`User: ${parsed.args}`); } const message = `${body}\n\n---\n\n${metaLines.join("\n")}`; + const textBlock: TextContent = { type: "text", text: message }; + const promptContent = images && images.length > 0 ? [textBlock, ...images] : message; const skillName = parsed.commandName.slice("skill:".length); const details: SkillPromptDetails = { name: skillName || parsed.commandName, @@ -79,7 +84,7 @@ export async function buildSkillCommandPrompt( return { message: { customType: SKILL_PROMPT_MESSAGE_TYPE, - content: message, + content: promptContent, display: true, details, attribution: "user", @@ -96,7 +101,7 @@ export async function invokeSkillCommandFromText( options?: InvokeSkillCommandOptions, ): Promise { try { - const built = await buildSkillCommandPrompt(ctx, text, streamingBehavior); + const built = await buildSkillCommandPrompt(ctx, text, streamingBehavior, options?.images); if (!built) return false; const promptOptions = options?.queueOnly ? { ...built.options, queueOnly: true } : built.options; await ctx.session.promptCustomMessage(built.message, promptOptions); diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index 4a831944d..ccc1599ad 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -672,6 +672,7 @@ export class UiHelpers { await invokeSkillCommandFromText(this.ctx, message.text, message.mode, { propagateErrors: true, queueOnly: true, + images: message.images, }) ) { return; @@ -770,7 +771,12 @@ export class UiHelpers { // is not sent as a literal prompt after compaction. let promptPromise: Promise; if (isKnownSkillCommand(this.ctx, firstPrompt.text)) { - const built = await buildSkillCommandPrompt(this.ctx, firstPrompt.text, firstPrompt.mode); + const built = await buildSkillCommandPrompt( + this.ctx, + firstPrompt.text, + firstPrompt.mode, + firstPrompt.images, + ); promptPromise = built ? this.ctx.session.promptCustomMessage(built.message, built.options).catch(restoreQueue) : Promise.resolve(); diff --git a/packages/coding-agent/test/input-controller-skill-queue.test.ts b/packages/coding-agent/test/input-controller-skill-queue.test.ts index dbe8a899b..c4ee940a6 100644 --- a/packages/coding-agent/test/input-controller-skill-queue.test.ts +++ b/packages/coding-agent/test/input-controller-skill-queue.test.ts @@ -268,8 +268,9 @@ describe("compaction skill re-invocation", () => { }); it("re-invokes a queued skill as a user-attributed skill prompt", async () => { + const image: ImageContent = { type: "image", data: "aGVsbG8=", mimeType: "image/png" }; const { ctx, promptCustomMessage, promptCustomMessageCalled, prompt, steer, followUp } = - createCompactionDrainContext([{ text: "/skill:test-skill arg1 arg2", mode: "followUp" }]); + createCompactionDrainContext([{ text: "/skill:test-skill arg1 arg2", mode: "followUp", images: [image] }]); const uiHelpers = new UiHelpers(ctx); await uiHelpers.flushCompactionQueue({ willRetry: false }); @@ -278,7 +279,11 @@ describe("compaction skill re-invocation", () => { const [message, options] = firstPromptCustomCall(promptCustomMessage); expect(message.customType).toBe(SKILL_PROMPT_MESSAGE_TYPE); expect(message.attribution).toBe("user"); - expect(message.content).toContain("Do the thing."); + if (!Array.isArray(message.content)) { + throw new Error("expected queued skill prompt to preserve image content blocks"); + } + expect(message.content[0]).toMatchObject({ type: "text", text: expect.stringContaining("Do the thing.") }); + expect(message.content[1]).toEqual(image); expect(message.details).toMatchObject({ name: "test-skill", args: "arg1 arg2", lineCount: 1 }); expect(options).toEqual({ streamingBehavior: "followUp", @@ -292,13 +297,22 @@ describe("compaction skill re-invocation", () => { it("queues retry-drained skills without appending them to session history", async () => { const fixture = await createRealSession(); try { - const { ctx } = createCompactionDrainContext([{ text: "/skill:test-skill retry args", mode: "followUp" }]); + const image: ImageContent = { type: "image", data: "cmV0cnk=", mimeType: "image/png" }; + const { ctx } = createCompactionDrainContext([ + { text: "/skill:test-skill retry args", mode: "followUp", images: [image] }, + ]); ctx.session = fixture.session; const uiHelpers = new UiHelpers(ctx); await uiHelpers.flushCompactionQueue({ willRetry: true }); expect(fixture.session.getQueuedMessages().followUp).toEqual(["/skill:test-skill retry args"]); + const queued = fixture.session.agent.peekFollowUpQueue()[0]; + if (queued?.role !== "custom" || !Array.isArray(queued.content)) { + throw new Error("expected retry-drained skill to be queued as image-bearing custom content"); + } + expect(queued.customType).toBe(SKILL_PROMPT_MESSAGE_TYPE); + expect(queued.content[1]).toEqual(image); expect(fixture.session.messages).toEqual([]); } finally { await fixture.session.dispose();