From de2a58f573c8acdd8a64c932c017a34702fc8730 Mon Sep 17 00:00:00 2001 From: sanjayrohith Date: Sun, 15 Mar 2026 15:29:39 +0530 Subject: [PATCH] fix(opencode): ignore whitespace-only prompts --- .../cli/cmd/tui/component/prompt/index.tsx | 68 ++++++---- packages/opencode/src/session/filled.ts | 11 ++ packages/opencode/src/session/prompt.ts | 126 +++++++++--------- packages/opencode/test/session/prompt.test.ts | 26 ++++ 4 files changed, 142 insertions(+), 89 deletions(-) create mode 100644 packages/opencode/src/session/filled.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index c85426cc2471..beae5dfbdeae 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -34,6 +34,7 @@ import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" +import { filled } from "@/session/filled" export type PromptProps = { sessionID?: string @@ -97,6 +98,16 @@ export function Prompt(props: PromptProps) { const pasteStyleId = syntax().getStyleId("extmark.paste")! let promptPartTypeId = 0 + function reset() { + input.extmarks.clear() + input.clear() + setStore("prompt", { + input: "", + parts: [], + }) + setStore("extmarkToPartIndex", new Map()) + } + sdk.event.on(TuiEvent.PromptAppend.type, (evt) => { if (!input || input.isDestroyed) return input.insertText(evt.properties.text) @@ -177,8 +188,7 @@ export function Prompt(props: PromptProps) { category: "Prompt", hidden: true, onSelect: (dialog) => { - input.extmarks.clear() - input.clear() + reset() dialog.clear() }, }, @@ -535,6 +545,32 @@ export function Prompt(props: PromptProps) { exit() return } + let inputText = store.prompt.input + + // Expand pasted text inline before submitting + const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId) + const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start) + + for (const extmark of sortedExtmarks) { + const partIndex = store.extmarkToPartIndex.get(extmark.id) + if (partIndex !== undefined) { + const part = store.prompt.parts[partIndex] + if (part?.type === "text" && part.text) { + const before = inputText.slice(0, extmark.start) + const after = inputText.slice(extmark.end) + inputText = before + part.text + after + } + } + } + + // Filter out text parts (pasted content) since they're now expanded inline + const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text") + + if (!filled([{ type: "text", text: inputText }, ...nonTextParts])) { + reset() + return + } + const selectedModel = local.model.current() if (!selectedModel) { promptModelWarning() @@ -562,26 +598,6 @@ export function Prompt(props: PromptProps) { } const messageID = MessageID.ascending() - let inputText = store.prompt.input - - // Expand pasted text inline before submitting - const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId) - const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start) - - for (const extmark of sortedExtmarks) { - const partIndex = store.extmarkToPartIndex.get(extmark.id) - if (partIndex !== undefined) { - const part = store.prompt.parts[partIndex] - if (part?.type === "text" && part.text) { - const before = inputText.slice(0, extmark.start) - const after = inputText.slice(extmark.end) - inputText = before + part.text + after - } - } - } - - // Filter out text parts (pasted content) since they're now expanded inline - const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text") // Capture mode before it gets reset const currentMode = store.mode @@ -655,12 +671,7 @@ export function Prompt(props: PromptProps) { ...store.prompt, mode: currentMode, }) - input.extmarks.clear() - setStore("prompt", { - input: "", - parts: [], - }) - setStore("extmarkToPartIndex", new Map()) + reset() props.onSubmit?.() // temporary hack to make sure the message is sent @@ -671,7 +682,6 @@ export function Prompt(props: PromptProps) { sessionID, }) }, 50) - input.clear() } const exit = useExit() diff --git a/packages/opencode/src/session/filled.ts b/packages/opencode/src/session/filled.ts new file mode 100644 index 000000000000..30d172db27ff --- /dev/null +++ b/packages/opencode/src/session/filled.ts @@ -0,0 +1,11 @@ +type Part = { + type: string + text?: string +} + +export function filled(parts: Part[]) { + return parts.some((part) => { + if (part.type !== "text") return true + return !!part.text?.trim() + }) +} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 743537f59871..3bbff784a739 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -48,6 +48,7 @@ import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" import { Truncate } from "@/tool/truncation" import { decodeDataUrl } from "@/util/data-url" +import { filled } from "./filled" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -91,71 +92,76 @@ export namespace SessionPrompt { if (match) throw new Session.BusyError(sessionID) } - export const PromptInput = z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod.optional(), - model: z - .object({ - providerID: ProviderID.zod, - modelID: ModelID.zod, - }) - .optional(), - agent: z.string().optional(), - noReply: z.boolean().optional(), - tools: z - .record(z.string(), z.boolean()) - .optional() - .describe( - "@deprecated tools and permissions have been merged, you can set permissions on the session itself now", - ), - format: MessageV2.Format.optional(), - system: z.string().optional(), - variant: z.string().optional(), - parts: z.array( - z.discriminatedUnion("type", [ - MessageV2.TextPart.omit({ - messageID: true, - sessionID: true, + export const PromptInput = z + .object({ + sessionID: SessionID.zod, + messageID: MessageID.zod.optional(), + model: z + .object({ + providerID: ProviderID.zod, + modelID: ModelID.zod, }) - .partial({ - id: true, + .optional(), + agent: z.string().optional(), + noReply: z.boolean().optional(), + tools: z + .record(z.string(), z.boolean()) + .optional() + .describe( + "@deprecated tools and permissions have been merged, you can set permissions on the session itself now", + ), + format: MessageV2.Format.optional(), + system: z.string().optional(), + variant: z.string().optional(), + parts: z.array( + z.discriminatedUnion("type", [ + MessageV2.TextPart.omit({ + messageID: true, + sessionID: true, }) - .meta({ - ref: "TextPartInput", - }), - MessageV2.FilePart.omit({ - messageID: true, - sessionID: true, - }) - .partial({ - id: true, + .partial({ + id: true, + }) + .meta({ + ref: "TextPartInput", + }), + MessageV2.FilePart.omit({ + messageID: true, + sessionID: true, }) - .meta({ - ref: "FilePartInput", - }), - MessageV2.AgentPart.omit({ - messageID: true, - sessionID: true, - }) - .partial({ - id: true, + .partial({ + id: true, + }) + .meta({ + ref: "FilePartInput", + }), + MessageV2.AgentPart.omit({ + messageID: true, + sessionID: true, }) - .meta({ - ref: "AgentPartInput", - }), - MessageV2.SubtaskPart.omit({ - messageID: true, - sessionID: true, - }) - .partial({ - id: true, + .partial({ + id: true, + }) + .meta({ + ref: "AgentPartInput", + }), + MessageV2.SubtaskPart.omit({ + messageID: true, + sessionID: true, }) - .meta({ - ref: "SubtaskPartInput", - }), - ]), - ), - }) + .partial({ + id: true, + }) + .meta({ + ref: "SubtaskPartInput", + }), + ]), + ), + }) + .refine((input) => filled(input.parts), { + message: "Prompt cannot be empty", + path: ["parts"], + }) export type PromptInput = z.infer export const prompt = fn(PromptInput, async (input) => { diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 3986271dab96..000037769abe 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -6,6 +6,7 @@ import { ModelID, ProviderID } from "../../src/provider/schema" import { Session } from "../../src/session" import { MessageV2 } from "../../src/session/message-v2" import { SessionPrompt } from "../../src/session/prompt" +import { SessionID } from "../../src/session/schema" import { Log } from "../../src/util/log" import { tmpdir } from "../fixture/fixture" @@ -210,3 +211,28 @@ describe("session.prompt agent variant", () => { } }) }) + +describe("session.prompt input", () => { + test("rejects whitespace-only prompts", () => { + const result = SessionPrompt.PromptInput.safeParse({ + sessionID: SessionID.make("ses_test"), + parts: [{ type: "text", text: " " }], + }) + + expect(result.success).toBe(false) + if (result.success) return + expect(result.error.issues[0]?.message).toBe("Prompt cannot be empty") + }) + + test("allows prompts with non-text parts", () => { + const result = SessionPrompt.PromptInput.safeParse({ + sessionID: SessionID.make("ses_test"), + parts: [ + { type: "text", text: " " }, + { type: "agent", name: "build" }, + ], + }) + + expect(result.success).toBe(true) + }) +})