From 404a43e02b4240aabe2fd6dae3c536bcc3e84fcc Mon Sep 17 00:00:00 2001 From: roboomp Date: Sun, 28 Jun 2026 16:42:39 +0000 Subject: [PATCH] fix(coding-agent): expand paste markers in Ctrl+Q follow-ups InputController.handleFollowUp read raw editor text via getText(), bypassing the paste-store expansion the Enter path applies through Editor.getExpandedText(). A large paste collapsed into a [Paste #N, +X lines] marker was therefore sent verbatim to the model when queued with Ctrl+Q / Ctrl+Enter, silently dropping the pasted content. Switch the follow-up path to getExpandedText() so queued submissions match the Enter path. Image markers are untouched; pendingImages forwarding is unchanged. Updated existing input-controller stubs (skill-queue, followup-image, keybindings) to implement getExpandedText, matching the production CustomEditor surface. Fixes #3737 --- packages/coding-agent/CHANGELOG.md | 4 + .../src/modes/controllers/input-controller.ts | 2 +- .../input-controller-followup-image.test.ts | 4 + ...ontroller-followup-paste-expansion.test.ts | 116 ++++++++++++++++++ .../test/input-controller-keybindings.test.ts | 4 + .../test/input-controller-skill-queue.test.ts | 4 + 6 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/test/input-controller-followup-paste-expansion.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 797543f64..d001c7115 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Ctrl+Q / Ctrl+Enter follow-up submissions sending the literal `[Paste #N]` marker instead of the expanded paste body ([#3737](https://github.com/can1357/oh-my-pi/issues/3737)). + ## [16.2.3] - 2026-06-28 ### Added diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 892cb5be7..3077558c0 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -1145,7 +1145,7 @@ export class InputController { /** Send editor text as a follow-up message (queued behind current stream). */ async handleFollowUp(): Promise { - let text = this.ctx.editor.getText().trim(); + let text = this.ctx.editor.getExpandedText().trim(); const images = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined; const imageLinks = images && this.ctx.editor.pendingImageLinks.length > 0 ? [...this.ctx.editor.pendingImageLinks] : undefined; diff --git a/packages/coding-agent/test/input-controller-followup-image.test.ts b/packages/coding-agent/test/input-controller-followup-image.test.ts index 4982e104d..337d539bc 100644 --- a/packages/coding-agent/test/input-controller-followup-image.test.ts +++ b/packages/coding-agent/test/input-controller-followup-image.test.ts @@ -13,6 +13,7 @@ import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/typ interface StubEditor { setText: (text: string) => void; getText: () => string; + getExpandedText: () => string; addToHistory: (text: string) => void; imageLinks?: unknown; pendingImages: ImageContent[]; @@ -37,6 +38,9 @@ function createContext(opts: { getText() { return editorText; }, + getExpandedText() { + return editorText; + }, addToHistory: vi.fn(), pendingImages: opts.pendingImages, pendingImageLinks: opts.pendingImageLinks ? [...opts.pendingImageLinks] : opts.pendingImages.map(() => undefined), diff --git a/packages/coding-agent/test/input-controller-followup-paste-expansion.test.ts b/packages/coding-agent/test/input-controller-followup-paste-expansion.test.ts new file mode 100644 index 000000000..a0c8c50c9 --- /dev/null +++ b/packages/coding-agent/test/input-controller-followup-paste-expansion.test.ts @@ -0,0 +1,116 @@ +/** + * Regression: queuing a follow-up message (Ctrl+Q / Ctrl+Enter → `app.message.followUp`) + * with a collapsed `[Paste #N, +X lines]` marker must expand the marker to its stored text + * before dispatch, identical to the Enter path. Previously `handleFollowUp` read raw + * `getText()` so the model received the literal marker string and the paste was silently + * dropped (issue #3737). + */ +import { afterEach, describe, expect, it, vi } from "bun:test"; +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 PromptOptionsLike { + streamingBehavior?: "steer" | "followUp"; +} + +describe("InputController.handleFollowUp paste-marker expansion", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("expands [Paste #N] markers to their stored text before dispatch while streaming", async () => { + const expanded = "line1\nline2\nline3\nline4\nline5"; + let editorText = "[Paste #1, +5 lines]"; + const prompt = vi.fn(async (_text: string, _options?: PromptOptionsLike) => {}); + const ctx = { + editor: { + setText(text: string) { + editorText = text; + }, + getText: () => editorText, + getExpandedText: () => + editorText.replace(/\[Paste #1(?:, (?:\+\d+ lines|\d+ chars))?\]/g, expanded), + addToHistory: vi.fn(), + pendingImages: [], + pendingImageLinks: [], + clearDraft(text?: string) { + if (text !== undefined) this.addToHistory(text); + this.setText(""); + }, + }, + ui: { requestRender: vi.fn() }, + skillCommands: new Map(), + session: { + isStreaming: true, + isCompacting: false, + isBashRunning: false, + isEvalRunning: false, + extensionRunner: undefined, + prompt, + }, + loopModeEnabled: false, + compactionQueuedMessages: [], + locallySubmittedUserSignatures: new Set(), + updatePendingMessagesDisplay: vi.fn(), + showError: vi.fn(), + withLocalSubmission: async (_text: string, fn: () => unknown) => fn(), + } as unknown as InteractiveModeContext; + + await new InputController(ctx).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(expanded); + expect(call[0]).not.toContain("[Paste #"); + expect(call[1]?.streamingBehavior).toBe("followUp"); + }); + + it("expands [Paste #N] markers when idle (not streaming)", async () => { + const expanded = "queued-paste-body\nspans multiple lines"; + let editorText = "[Paste #2, +2 lines]"; + const prompt = vi.fn(async (_text: string, _options?: PromptOptionsLike) => {}); + const ctx = { + editor: { + setText(text: string) { + editorText = text; + }, + getText: () => editorText, + getExpandedText: () => + editorText.replace(/\[Paste #2(?:, (?:\+\d+ lines|\d+ chars))?\]/g, expanded), + addToHistory: vi.fn(), + pendingImages: [], + pendingImageLinks: [], + clearDraft(text?: string) { + if (text !== undefined) this.addToHistory(text); + this.setText(""); + }, + }, + ui: { requestRender: vi.fn() }, + skillCommands: new Map(), + session: { + isStreaming: false, + isCompacting: false, + isBashRunning: false, + isEvalRunning: false, + extensionRunner: undefined, + prompt, + }, + loopModeEnabled: false, + compactionQueuedMessages: [], + locallySubmittedUserSignatures: new Set(), + updatePendingMessagesDisplay: vi.fn(), + showError: vi.fn(), + withLocalSubmission: async (_text: string, fn: () => unknown) => fn(), + } as unknown as InteractiveModeContext; + + await new InputController(ctx).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(expanded); + expect(call[0]).not.toContain("[Paste #"); + expect(call[1]?.streamingBehavior).toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/test/input-controller-keybindings.test.ts b/packages/coding-agent/test/input-controller-keybindings.test.ts index cc4bf0b18..772dbf9cd 100644 --- a/packages/coding-agent/test/input-controller-keybindings.test.ts +++ b/packages/coding-agent/test/input-controller-keybindings.test.ts @@ -26,6 +26,7 @@ type FakeEditor = { onSubmit?: (text: string) => Promise; setText(text: string): void; getText(): string; + getExpandedText(): string; addToHistory(text: string): void; setActionKeys(action: string, keys: string[]): void; setCustomKeyHandler(key: string, handler: () => void): void; @@ -105,6 +106,9 @@ async function createContext() { getText() { return editorText; }, + getExpandedText() { + return editorText; + }, addToHistory: vi.fn(), pasteText(text: string) { editorText += text; 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 d16d02265..6f351c719 100644 --- a/packages/coding-agent/test/input-controller-skill-queue.test.ts +++ b/packages/coding-agent/test/input-controller-skill-queue.test.ts @@ -27,6 +27,7 @@ import { TempDir } from "@oh-my-pi/pi-utils"; type StubEditor = { setText: (text: string) => void; getText: () => string; + getExpandedText: () => string; clearDraft: (historyText?: string) => void; addToHistory: Mock<(...args: unknown[]) => unknown>; onSubmit?: (text: string) => Promise; @@ -67,6 +68,9 @@ function createStubInputControllerContext(opts: { getText() { return editorText; }, + getExpandedText() { + return editorText; + }, clearDraft(historyText?: string) { if (historyText !== undefined) this.addToHistory(historyText); this.setText("");