fix(tui): restore streaming steer image draft on prompt error
Wrap the streaming Enter steer dispatch in try/catch so prompt failures restore text plus pendingImages / pendingImageLinks / imageLinks and surface showError instead of losing an image-only draft.\n\nAlso remove a forbidden ReturnType<> from the follow-up image regression helper.\n\nFixes #3467
This commit is contained in:
@@ -719,11 +719,25 @@ export class InputController {
|
||||
// (a user-role `message_start` event) leaves any draft the user has
|
||||
// typed since queuing intact. Same protection as #783, applied to
|
||||
// the streaming/queue path.
|
||||
await this.ctx.withLocalSubmission(
|
||||
text,
|
||||
() => this.ctx.session.prompt(text, { streamingBehavior: "steer", images }),
|
||||
{ imageCount: images?.length ?? 0 },
|
||||
);
|
||||
try {
|
||||
await this.ctx.withLocalSubmission(
|
||||
text,
|
||||
() => this.ctx.session.prompt(text, { streamingBehavior: "steer", images }),
|
||||
{ imageCount: images?.length ?? 0 },
|
||||
);
|
||||
} catch (error) {
|
||||
// Don't lose the queued steer draft: restore text and images so
|
||||
// the user can retry after dispatch validation/queue failures.
|
||||
this.ctx.editor.setText(text);
|
||||
if (images && images.length > 0) {
|
||||
this.ctx.editor.pendingImages = [...images];
|
||||
this.ctx.editor.pendingImageLinks = inputImageLinks
|
||||
? [...inputImageLinks]
|
||||
: images.map(() => undefined);
|
||||
this.ctx.editor.imageLinks = this.ctx.editor.pendingImageLinks;
|
||||
}
|
||||
this.ctx.showError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
this.ctx.updatePendingMessagesDisplay();
|
||||
this.ctx.ui.requestRender();
|
||||
return;
|
||||
|
||||
@@ -51,6 +51,7 @@ function createContext(opts: {
|
||||
const prompt = vi.fn(async (_text: string, _options?: PromptOptionsLike) => {});
|
||||
const updatePendingMessagesDisplay = vi.fn();
|
||||
const requestRender = vi.fn();
|
||||
const showError = vi.fn();
|
||||
|
||||
const ctx = {
|
||||
editor,
|
||||
@@ -68,11 +69,11 @@ function createContext(opts: {
|
||||
compactionQueuedMessages: [],
|
||||
locallySubmittedUserSignatures: new Set<string>(),
|
||||
updatePendingMessagesDisplay,
|
||||
showError: vi.fn(),
|
||||
showError,
|
||||
withLocalSubmission: async (_text: string, fn: () => unknown) => fn(),
|
||||
} as unknown as InteractiveModeContext;
|
||||
|
||||
return { ctx, editor, prompt, showError: ctx.showError as ReturnType<typeof vi.fn> };
|
||||
return { ctx, editor, prompt, showError };
|
||||
}
|
||||
|
||||
describe("InputController.handleFollowUp image forwarding", () => {
|
||||
|
||||
@@ -4,7 +4,11 @@ import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/inp
|
||||
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
|
||||
import { USER_INTERRUPT_LABEL } from "@oh-my-pi/pi-coding-agent/session/messages";
|
||||
|
||||
function createContext(options?: { queuedMessageCount?: number; pendingImages?: ImageContent[] }) {
|
||||
function createContext(options?: {
|
||||
queuedMessageCount?: number;
|
||||
pendingImages?: ImageContent[];
|
||||
pendingImageLinks?: (string | undefined)[];
|
||||
}) {
|
||||
let editorText = "";
|
||||
const abort = vi.fn(async () => {});
|
||||
const prompt = vi.fn(async () => {});
|
||||
@@ -22,7 +26,10 @@ function createContext(options?: { queuedMessageCount?: number; pendingImages?:
|
||||
},
|
||||
addToHistory: vi.fn(),
|
||||
pendingImages: options?.pendingImages ? [...options.pendingImages] : ([] as ImageContent[]),
|
||||
pendingImageLinks: options?.pendingImages?.map(() => undefined) ?? ([] as (string | undefined)[]),
|
||||
pendingImageLinks:
|
||||
options?.pendingImageLinks ??
|
||||
options?.pendingImages?.map(() => undefined) ??
|
||||
([] as (string | undefined)[]),
|
||||
},
|
||||
ui: { requestRender },
|
||||
session: {
|
||||
@@ -86,6 +93,31 @@ describe("empty submit with queued messages", () => {
|
||||
expect(requestRender).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("restores an image-only steer when streaming dispatch rejects", async () => {
|
||||
const image: ImageContent = { type: "image", mimeType: "image/png", data: "aW1hZ2U=" };
|
||||
const { ctx, abort, prompt, showError, updatePendingMessagesDisplay, requestRender } = createContext({
|
||||
queuedMessageCount: 0,
|
||||
pendingImages: [image],
|
||||
pendingImageLinks: ["local://draft.png"],
|
||||
});
|
||||
prompt.mockImplementationOnce(async () => {
|
||||
throw new Error("queue rejected");
|
||||
});
|
||||
const controller = new InputController(ctx);
|
||||
controller.setupEditorSubmitHandler();
|
||||
|
||||
await ctx.editor.onSubmit?.("");
|
||||
|
||||
expect(abort).not.toHaveBeenCalled();
|
||||
expect(showError).toHaveBeenCalledWith("queue rejected");
|
||||
expect(ctx.editor.getText()).toBe("");
|
||||
expect(ctx.editor.pendingImages).toEqual([image]);
|
||||
expect(ctx.editor.pendingImageLinks).toEqual(["local://draft.png"]);
|
||||
expect(ctx.editor.imageLinks).toEqual(["local://draft.png"]);
|
||||
expect(updatePendingMessagesDisplay).toHaveBeenCalledTimes(1);
|
||||
expect(requestRender).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("queues an image-only steer instead of aborting when messages are already queued", async () => {
|
||||
const image: ImageContent = { type: "image", mimeType: "image/png", data: "aW1hZ2U=" };
|
||||
const { ctx, abort, prompt } = createContext({ queuedMessageCount: 1, pendingImages: [image] });
|
||||
|
||||
Reference in New Issue
Block a user