From 48bf701281433372077f5a66806d5004a291c7fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Wed, 27 May 2026 17:12:56 +0000 Subject: [PATCH] fix(moderator): detect empty edge prompt after template rendering (#553) When mustache variables in edge prompts resolve to empty strings (because upstream output lacks the fields), the engine now returns a Result.error instead of passing an empty --prompt to the agent. - evaluate.ts: check rendered prompt is non-empty after mustache.render() - run.ts: improve parseArgv error message for empty --prompt - Export parseArgv for testability - Add 7 tests covering all cases from the spec --- .../src/moderator/__tests__/evaluate.test.ts | 91 +++++++++++++++++++ .../cli-workflow/src/moderator/evaluate.ts | 8 ++ .../src/__tests__/parseArgv.test.ts | 45 +++++++++ packages/workflow-util-agent/src/index.ts | 2 +- packages/workflow-util-agent/src/run.ts | 7 +- 5 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 packages/workflow-util-agent/src/__tests__/parseArgv.test.ts diff --git a/packages/cli-workflow/src/moderator/__tests__/evaluate.test.ts b/packages/cli-workflow/src/moderator/__tests__/evaluate.test.ts index e929481..8cc0a1f 100644 --- a/packages/cli-workflow/src/moderator/__tests__/evaluate.test.ts +++ b/packages/cli-workflow/src/moderator/__tests__/evaluate.test.ts @@ -1,6 +1,97 @@ import { describe, expect, test } from "vitest"; import { evaluate } from "../evaluate.js"; +describe("Edge prompt template variable resolution", () => { + test("returns error when rendered prompt is empty string", () => { + const graph = { + $START: { + _: { role: "classifier", prompt: "{{{userPrompt}}}", location: null }, + }, + }; + + const result = evaluate(graph, "$START", {}); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain("prompt"); + expect(result.error.message).toContain("empty"); + } + }); + + test("returns error when rendered prompt is whitespace-only", () => { + const graph = { + $START: { + _: { role: "classifier", prompt: " {{{userPrompt}}} ", location: null }, + }, + }; + + const result = evaluate(graph, "$START", {}); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain("prompt"); + expect(result.error.message).toContain("empty"); + } + }); + + test("succeeds when all template variables resolve to non-empty values", () => { + const graph = { + $START: { + _: { role: "classifier", prompt: "{{{userPrompt}}}", location: null }, + }, + }; + + const result = evaluate(graph, "$START", { userPrompt: "Fix the bug" }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.prompt).toBe("Fix the bug"); + } + }); + + test("succeeds with static (no-variable) prompt", () => { + const graph = { + $START: { + _: { role: "classifier", prompt: "Classify this input", location: null }, + }, + }; + + const result = evaluate(graph, "$START", {}); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.prompt).toBe("Classify this input"); + } + }); + + test("succeeds when prompt has mix of static text and unresolved variables", () => { + const graph = { + $START: { + _: { role: "classifier", prompt: "Please handle: {{{userPrompt}}}", location: null }, + }, + }; + + const result = evaluate(graph, "$START", {}); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.prompt).toBe("Please handle: "); + } + }); + + test("returns error when ALL variables missing and no static text remains", () => { + const graph = { + $START: { + _: { role: "classifier", prompt: "{{{a}}}{{{b}}}", location: null }, + }, + }; + + const result = evaluate(graph, "$START", {}); + + expect(result.ok).toBe(false); + }); +}); + describe("Moderator location resolution", () => { test("returns null location when edge has no location field", () => { const graph = { diff --git a/packages/cli-workflow/src/moderator/evaluate.ts b/packages/cli-workflow/src/moderator/evaluate.ts index 09d93fd..1f9bad4 100644 --- a/packages/cli-workflow/src/moderator/evaluate.ts +++ b/packages/cli-workflow/src/moderator/evaluate.ts @@ -43,6 +43,14 @@ export function evaluate( try { const prompt = mustache.render(target.prompt, lastOutput); + if (prompt.trim() === "") { + return { + ok: false, + error: new Error( + `edge prompt resolved to empty string for role "${target.role}" (template: "${target.prompt}"). Check that upstream output includes required variables.`, + ), + }; + } const location = target.location !== null ? mustache.render(target.location, lastOutput) : null; return { ok: true, value: { role: target.role, prompt, location } }; } catch (error) { diff --git a/packages/workflow-util-agent/src/__tests__/parseArgv.test.ts b/packages/workflow-util-agent/src/__tests__/parseArgv.test.ts new file mode 100644 index 0000000..ec5dddc --- /dev/null +++ b/packages/workflow-util-agent/src/__tests__/parseArgv.test.ts @@ -0,0 +1,45 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +describe("parseArgv empty prompt error message", () => { + let stderrOutput: string; + let _exitCode: number | null; + const originalExit = process.exit; + const originalStderrWrite = process.stderr.write; + + beforeEach(() => { + stderrOutput = ""; + _exitCode = null; + process.exit = ((code?: number) => { + _exitCode = code ?? 1; + throw new Error("process.exit called"); + }) as any; + process.stderr.write = ((chunk: string) => { + stderrOutput += chunk; + return true; + }) as any; + }); + + afterEach(() => { + process.exit = originalExit; + process.stderr.write = originalStderrWrite; + }); + + test("empty prompt produces error message mentioning template variables", async () => { + const { parseArgv } = await import("../run.js"); + const argv = [ + "node", + "uwf-hermes", + "--thread", + "01ABCDEFGHIJKLMNOPQRSTUVWX", + "--role", + "classifier", + "--prompt", + "", + ]; + + expect(() => parseArgv(argv)).toThrow("process.exit called"); + expect(stderrOutput).toContain("prompt"); + expect(stderrOutput).toContain("empty"); + expect(stderrOutput).toContain("template"); + }); +}); diff --git a/packages/workflow-util-agent/src/index.ts b/packages/workflow-util-agent/src/index.ts index 0660f40..bd32100 100644 --- a/packages/workflow-util-agent/src/index.ts +++ b/packages/workflow-util-agent/src/index.ts @@ -11,7 +11,7 @@ export { } from "./extract.js"; export type { FrontmatterFastPathResult } from "./frontmatter.js"; export { tryFrontmatterFastPath } from "./frontmatter.js"; -export { createAgent } from "./run.js"; +export { createAgent, parseArgv } from "./run.js"; export { getCachedSessionId, getCachePath, setCachedSessionId } from "./session-cache.js"; export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js"; export type { diff --git a/packages/workflow-util-agent/src/run.ts b/packages/workflow-util-agent/src/run.ts index bcb1106..1090ccb 100644 --- a/packages/workflow-util-agent/src/run.ts +++ b/packages/workflow-util-agent/src/run.ts @@ -32,13 +32,16 @@ function getNamedArg(argv: string[], name: string): string { return argv[idx + 1]; } -function parseArgv(argv: string[]): { threadId: ThreadId; role: string; prompt: string } { +export function parseArgv(argv: string[]): { threadId: ThreadId; role: string; prompt: string } { const threadId = getNamedArg(argv, "--thread"); const role = getNamedArg(argv, "--role"); const prompt = getNamedArg(argv, "--prompt"); if (threadId === "") fail(USAGE); if (role === "") fail(USAGE); - if (prompt === "") fail(USAGE); + if (prompt === "") + fail( + `--prompt is empty. If this agent was spawned by uwf, the edge prompt template may have unresolved variables. ${USAGE}`, + ); return { threadId: threadId as ThreadId, role, prompt }; }