Files
oh-my-pi/packages/coding-agent/test/input-controller-followup-image.test.ts
T
can1357 93db34b0d5 refactor(coding-agent): consolidated editor state and unify transcript rendering
- Centralized draft state and image management by migrating fields from context to the CustomEditor component.
- Standardized transcript row construction by introducing shared helpers for background jobs, IRC traffic, and file mentions.
- Refactored redundant UI logic and helper functions into reusable utility modules to streamline message submission and component rendering.
- Standardized event handler types by consolidating lifecycle definitions into a shared module while maintaining public API stability.
2026-06-22 06:11:57 +02:00

128 lines
4.4 KiB
TypeScript

/**
* Regression: queuing a follow-up message (Ctrl+Enter / `app.message.followUp`)
* with a pending clipboard-pasted image must forward the image to
* `session.prompt`. Previously `handleFollowUp` ignored `pendingImages`, so the
* queued message reached the model as text only and the image was silently
* dropped.
*/
import { afterEach, describe, expect, it, vi } from "bun:test";
import type { ImageContent } from "@oh-my-pi/pi-ai";
import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller";
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
interface StubEditor {
setText: (text: string) => void;
getText: () => string;
addToHistory: (text: string) => void;
imageLinks?: unknown;
pendingImages: ImageContent[];
pendingImageLinks: (string | undefined)[];
clearDraft: (text?: string) => void;
}
interface PromptOptionsLike {
streamingBehavior?: "steer" | "followUp";
images?: ImageContent[];
}
function createContext(opts: { isStreaming: boolean; pendingImages: ImageContent[] }) {
let editorText = "";
const editor: StubEditor = {
setText(text) {
editorText = text;
},
getText() {
return editorText;
},
addToHistory: vi.fn(),
pendingImages: opts.pendingImages,
pendingImageLinks: opts.pendingImages.map(() => undefined),
clearDraft(text?: string) {
if (text !== undefined) this.addToHistory(text);
this.setText("");
this.imageLinks = undefined;
this.pendingImages = [];
this.pendingImageLinks = [];
},
};
const prompt = vi.fn(async (_text: string, _options?: PromptOptionsLike) => {});
const updatePendingMessagesDisplay = vi.fn();
const requestRender = vi.fn();
const ctx = {
editor,
ui: { requestRender },
skillCommands: new Map<string, string>(),
session: {
isStreaming: opts.isStreaming,
isCompacting: false,
isBashRunning: false,
isEvalRunning: false,
extensionRunner: undefined,
prompt,
},
loopModeEnabled: false,
compactionQueuedMessages: [],
locallySubmittedUserSignatures: new Set<string>(),
updatePendingMessagesDisplay,
withLocalSubmission: async (_text: string, fn: () => unknown) => fn(),
} as unknown as InteractiveModeContext;
return { ctx, editor, prompt };
}
describe("InputController.handleFollowUp image forwarding", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("forwards pending images to session.prompt while streaming and clears them", async () => {
const image: ImageContent = { type: "image", mimeType: "image/png", data: "aGVsbG8=" };
const { ctx, editor, prompt } = createContext({ isStreaming: true, pendingImages: [image] });
const controller = new InputController(ctx);
editor.setText("[Image #1] look at this");
await controller.handleFollowUp();
expect(prompt).toHaveBeenCalledTimes(1);
const call = prompt.mock.calls[0];
if (!call) throw new Error("expected session.prompt to be called");
expect(call[0]).toBe("[Image #1] look at this");
expect(call[1]?.streamingBehavior).toBe("followUp");
expect(call[1]?.images).toEqual([image]);
// Pending image state is consumed so the next message does not resend it.
expect(ctx.editor.pendingImages).toEqual([]);
expect(ctx.editor.pendingImageLinks).toEqual([]);
});
it("forwards pending images when not streaming", async () => {
const image: ImageContent = { type: "image", mimeType: "image/png", data: "d29ybGQ=" };
const { ctx, editor, prompt } = createContext({ isStreaming: false, pendingImages: [image] });
const controller = new InputController(ctx);
editor.setText("[Image #1] describe it");
await controller.handleFollowUp();
expect(prompt).toHaveBeenCalledTimes(1);
const call = prompt.mock.calls[0];
if (!call) throw new Error("expected session.prompt to be called");
expect(call[1]?.images).toEqual([image]);
expect(call[1]?.streamingBehavior).toBeUndefined();
expect(ctx.editor.pendingImages).toEqual([]);
});
it("omits images when none are pending", async () => {
const { ctx, editor, prompt } = createContext({ isStreaming: true, pendingImages: [] });
const controller = new InputController(ctx);
editor.setText("just text");
await controller.handleFollowUp();
expect(prompt).toHaveBeenCalledTimes(1);
const call = prompt.mock.calls[0];
if (!call) throw new Error("expected session.prompt to be called");
expect(call[1]?.images).toBeUndefined();
expect(call[1]?.streamingBehavior).toBe("followUp");
});
});