fix(coding-agent): preserved queued skill images

Kept image content attached to compaction-queued skill prompts when they are rebuilt as custom messages.
This commit is contained in:
roboomp
2026-06-28 04:46:26 +00:00
parent 9cfbece323
commit 796bf51f46
3 changed files with 32 additions and 7 deletions
@@ -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<BuiltSkillCommandPrompt | undefined> {
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<boolean> {
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);
@@ -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<unknown>;
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();
@@ -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();