From ede4f1ce354a2d01857875bc3e14ed26b44e5112 Mon Sep 17 00:00:00 2001 From: li-xiu-qi Date: Tue, 27 Oct 2026 14:00:53 +0800 Subject: [PATCH] fix(coding-agent): reject empty clarify_user answers in TUI Pressing Enter with an empty freeform answer in the native TUI dialog previously did nothing. Show an inline validation error, keep the current input, and clear the error once the user types. Esc still cancels. Fixes #311 --- .../src/features/step-questionnaire.ts | 18 +++- .../311-clarify-tui-empty-submission.test.ts | 93 +++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/test/suite/regressions/311-clarify-tui-empty-submission.test.ts diff --git a/packages/coding-agent/src/features/step-questionnaire.ts b/packages/coding-agent/src/features/step-questionnaire.ts index 7301fc9a..9e198489 100644 --- a/packages/coding-agent/src/features/step-questionnaire.ts +++ b/packages/coding-agent/src/features/step-questionnaire.ts @@ -263,6 +263,7 @@ async function executeNativeDialog( let optionIndex = 0; let inputMode = questions[0]?.options.length === 0; let inputQuestionId: string | null = inputMode ? (questions[0]?.id ?? null) : null; + let emptyAnswerError = false; let cachedLines: string[] | undefined; const answers = new Map(); const totalTabs = questions.length + 1; @@ -306,6 +307,7 @@ async function executeNativeDialog( const question = currentQuestion(); inputMode = Boolean(question && question.options.length === 0); inputQuestionId = inputMode ? (question?.id ?? null) : null; + emptyAnswerError = false; editor.setText(""); refresh(); }; @@ -335,7 +337,12 @@ async function executeNativeDialog( editor.onSubmit = (value) => { if (!inputQuestionId) return; const answer = value.trim(); - if (!answer) return; + if (!answer) { + emptyAnswerError = true; + refresh(); + return; + } + emptyAnswerError = false; saveAnswer(inputQuestionId, answer, answer, true); editor.setText(""); inputMode = false; @@ -343,6 +350,12 @@ async function executeNativeDialog( advance(); }; + editor.onChange = () => { + if (!emptyAnswerError) return; + emptyAnswerError = false; + refresh(); + }; + const handleInput = (data: string): void => { if (matchesKey(data, Key.escape)) { if (inputMode && currentQuestion()?.options.length) { @@ -489,6 +502,9 @@ async function executeNativeDialog( for (const line of editor.render(Math.max(1, renderWidth - 2))) { lines.push(` ${line}`); } + if (emptyAnswerError) { + addWrapped(" ", theme.fg("warning", "Enter an answer before submitting")); + } } } diff --git a/packages/coding-agent/test/suite/regressions/311-clarify-tui-empty-submission.test.ts b/packages/coding-agent/test/suite/regressions/311-clarify-tui-empty-submission.test.ts new file mode 100644 index 00000000..8984f481 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/311-clarify-tui-empty-submission.test.ts @@ -0,0 +1,93 @@ +import type { AgentToolResult } from "@step-harness/agent-core"; +import { describe, expect, test, vi } from "vitest"; +import type { ExtensionContext } from "../../../src/core/extensions/index.ts"; +import { + registerStepClarifyUserExtension, + type StepQuestionnaireDetails, +} from "../../../src/features/step-questionnaire.ts"; +import { createHarness, type Harness } from "../harness.ts"; + +// Regression for https://github.com/stepfun-ai/Step-Code/issues/311 + +type QuestionnaireComponent = { + render(width: number): string[]; + handleInput(data: string): void; + invalidate(): void; +}; + +describe("clarify_user native TUI empty submission (#311)", () => { + test("shows a validation error for an empty freeform answer and keeps the dialog open", async () => { + let harness: Harness | undefined; + + try { + harness = await createHarness({ extensionFactories: [registerStepClarifyUserExtension] }); + const tool = harness.session.extensionRunner + .getAllRegisteredTools() + .find(({ definition }) => definition.name === "clarify_user"); + if (!tool) throw new Error("clarify_user is not registered"); + + const baseContext = harness.session.extensionRunner.createContext(); + let component: QuestionnaireComponent | undefined; + let finishDialog: ((details: StepQuestionnaireDetails) => void) | undefined; + const dialogResult = new Promise((resolve) => { + finishDialog = resolve; + }); + const fakeTui = { + requestRender: () => {}, + terminal: { rows: 24 }, + } as unknown as never; + const fakeTheme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + bg: (_color: string, text: string) => text, + } as unknown as never; + const context = { + ...baseContext, + mode: "tui", + hasUI: true, + ui: { + ...baseContext.ui, + custom: async ( + factory: ( + tui: never, + theme: never, + keybindings: never, + done: (details: StepQuestionnaireDetails) => void, + ) => Promise, + ) => { + component = await factory(fakeTui, fakeTheme, {} as never, (details) => { + finishDialog?.(details); + }); + return dialogResult; + }, + }, + } as unknown as ExtensionContext; + + const execution = tool.definition.execute( + "clarify-tui-empty", + { question: "Any notes?" }, + undefined, + undefined, + context, + ) as Promise>; + + await vi.waitFor(() => expect(component).toBeDefined()); + const dialog = component as QuestionnaireComponent; + + dialog.handleInput("\r"); + expect(dialog.render(80).join("\n")).toContain("Enter an answer before submitting"); + + for (const character of "Fixed") dialog.handleInput(character); + expect(dialog.render(80).join("\n")).not.toContain("Enter an answer before submitting"); + + dialog.handleInput("\r"); + const result = await execution; + expect(result.details).toMatchObject({ + cancelled: false, + answers: [{ id: "answer", value: "Fixed", label: "Fixed", wasCustom: true }], + }); + } finally { + harness?.cleanup(); + } + }); +});