fix(moderator): detect empty edge prompt after template rendering (#553)
CI / test (pull_request) Successful in 1m31s

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
This commit is contained in:
2026-05-27 17:12:56 +00:00
parent 3b498069b6
commit 48bf701281
5 changed files with 150 additions and 3 deletions
@@ -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 = {
@@ -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) {
@@ -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");
});
});
+1 -1
View File
@@ -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 {
+5 -2
View File
@@ -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 };
}