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(); + } + }); +});