Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoju 48bf701281 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
2026-05-27 17:17:39 +00:00
10 changed files with 155 additions and 259 deletions
@@ -1,227 +0,0 @@
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ThreadId } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { createMarker, deleteMarker } from "../background/index.js";
import { cmdThreadShow, cmdThreadStart } from "../commands/thread.js";
import { appendThreadHistory, loadThreadsIndex } from "../store.js";
const TEST_WORKFLOW_YAML = `
name: test-status
description: Test workflow for status field
roles:
planner:
description: Plans the work
goal: Plan implementation
capabilities: ["planning"]
procedure: Plan
output: |
$status: "ready"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string }
graph:
$START:
_:
role: planner
prompt: "Plan the work"
location: null
planner:
_:
role: $END
prompt: "Done"
location: null
`;
describe("thread show status field", () => {
let tmpDir: string;
let storageRoot: string;
async function setupTestEnv() {
tmpDir = join(tmpdir(), `uwf-test-status-${Date.now()}`);
storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
}
async function teardown() {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true });
}
}
test("active idle thread shows status 'idle'", async () => {
await setupTestEnv();
const workflowPath = join(tmpDir, "test-status.yaml");
await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8");
// Create a thread
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
const threadId = startResult.thread as ThreadId;
// Show the thread (should be idle)
const result = await cmdThreadShow(storageRoot, threadId);
expect(result.status).toBe("idle");
expect(result.done).toBe(false);
expect(result.background).toBe(null);
expect(result.thread).toBe(threadId);
await teardown();
});
test("active running thread shows status 'running'", async () => {
await setupTestEnv();
const workflowPath = join(tmpDir, "test-status.yaml");
await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8");
// Create a thread
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
const threadId = startResult.thread as ThreadId;
const workflow = startResult.workflow;
// Create a running marker
await createMarker(storageRoot, {
thread: threadId,
workflow,
pid: process.pid,
startedAt: Date.now(),
});
try {
const result = await cmdThreadShow(storageRoot, threadId);
expect(result.status).toBe("running");
expect(result.done).toBe(false);
expect(result.background).toBe(null);
expect(result.thread).toBe(threadId);
} finally {
// Cleanup: delete marker
await deleteMarker(storageRoot, threadId);
await teardown();
}
});
test("completed thread shows status 'completed'", async () => {
await setupTestEnv();
const workflowPath = join(tmpDir, "test-status.yaml");
await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8");
// Create a thread
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
const threadId = startResult.thread as ThreadId;
const workflow = startResult.workflow;
// Get the head hash before moving to history
const index = await loadThreadsIndex(storageRoot);
const head = index[threadId];
if (!head) throw new Error("Thread not found in index");
// Move thread to history with reason 'completed'
const { saveThreadsIndex } = await import("../store.js");
const newIndex = { ...index };
delete newIndex[threadId];
await saveThreadsIndex(storageRoot, newIndex);
await appendThreadHistory(storageRoot, {
thread: threadId,
workflow,
head,
completedAt: Date.now(),
reason: "completed",
});
const result = await cmdThreadShow(storageRoot, threadId);
expect(result.status).toBe("completed");
expect(result.done).toBe(true);
expect(result.background).toBe(null);
expect(result.thread).toBe(threadId);
await teardown();
});
test("cancelled thread shows status 'cancelled'", async () => {
await setupTestEnv();
const workflowPath = join(tmpDir, "test-status.yaml");
await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8");
// Create a thread
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
const threadId = startResult.thread as ThreadId;
const workflow = startResult.workflow;
// Get the head hash before moving to history
const index = await loadThreadsIndex(storageRoot);
const head = index[threadId];
if (!head) throw new Error("Thread not found in index");
// Move thread to history with reason 'cancelled'
const { saveThreadsIndex } = await import("../store.js");
const newIndex = { ...index };
delete newIndex[threadId];
await saveThreadsIndex(storageRoot, newIndex);
await appendThreadHistory(storageRoot, {
thread: threadId,
workflow,
head,
completedAt: Date.now(),
reason: "cancelled",
});
const result = await cmdThreadShow(storageRoot, threadId);
expect(result.status).toBe("cancelled");
expect(result.done).toBe(true);
expect(result.background).toBe(null);
expect(result.thread).toBe(threadId);
await teardown();
});
test("legacy completed thread without reason shows status 'completed'", async () => {
await setupTestEnv();
const workflowPath = join(tmpDir, "test-status.yaml");
await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8");
// Create a thread
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
const threadId = startResult.thread as ThreadId;
const workflow = startResult.workflow;
// Get the head hash before moving to history
const index = await loadThreadsIndex(storageRoot);
const head = index[threadId];
if (!head) throw new Error("Thread not found in index");
// Move thread to history with reason null (legacy format)
const { saveThreadsIndex } = await import("../store.js");
const newIndex = { ...index };
delete newIndex[threadId];
await saveThreadsIndex(storageRoot, newIndex);
await appendThreadHistory(storageRoot, {
thread: threadId,
workflow,
head,
completedAt: Date.now(),
reason: null,
});
const result = await cmdThreadShow(storageRoot, threadId);
expect(result.status).toBe("completed");
expect(result.done).toBe(true);
expect(result.background).toBe(null);
await teardown();
});
});
+2 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import type { CasRef, ThreadId, ThreadStatus } from "@uncaged/workflow-protocol";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { Command } from "commander";
import {
cmdCasGet,
@@ -38,6 +38,7 @@ import {
cmdThreadStart,
cmdThreadStop,
THREAD_READ_DEFAULT_QUOTA,
type ThreadStatus,
} from "./commands/thread.js";
import { parseTimeInput } from "./commands/thread-time-parser.js";
import { cmdWorkflowAdd, cmdWorkflowList, cmdWorkflowShow } from "./commands/workflow.js";
+2 -16
View File
@@ -12,7 +12,6 @@ import type {
StepOutput,
ThreadId,
ThreadListItem,
ThreadStatus,
ThreadsIndex,
WorkflowConfig,
WorkflowPayload,
@@ -316,16 +315,10 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
if (workflow === null) {
fail(`failed to resolve workflow from head: ${activeHead}`);
}
// Check if thread is running
const runningMarker = await isThreadRunning(storageRoot, threadId);
const status: ThreadStatus = runningMarker !== null ? "running" : "idle";
return {
workflow,
thread: threadId,
head: activeHead,
status,
done: false,
background: null,
};
@@ -333,13 +326,10 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
const hist = await findThreadInHistory(storageRoot, threadId);
if (hist !== null) {
const status: ThreadStatus = hist.reason === "cancelled" ? "cancelled" : "completed";
return {
workflow: hist.workflow,
thread: threadId,
head: hist.head,
status,
done: true,
background: null,
};
@@ -348,6 +338,8 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
fail(`thread not found: ${threadId}`);
}
export type ThreadStatus = "idle" | "running" | "completed" | "cancelled";
export type ThreadListItemWithStatus = ThreadListItem & {
status: ThreadStatus;
};
@@ -955,7 +947,6 @@ async function cmdThreadStepBackground(
workflow: workflowHash,
thread: threadId,
head: headHash,
status: "running",
done: false,
background: true,
},
@@ -998,7 +989,6 @@ async function cmdThreadStepOnce(
workflow: workflowHash,
thread: threadId,
head: headHash,
status: "completed",
done: true,
background: null,
};
@@ -1051,14 +1041,10 @@ async function cmdThreadStepOnce(
await archiveThread(storageRoot, threadId, workflowHash, newHead);
}
// Determine status based on whether thread is done and running state
const status: ThreadStatus = done ? "completed" : "idle";
return {
workflow: workflowHash,
thread: threadId,
head: newHead,
status,
done,
background: null,
};
@@ -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) {
-1
View File
@@ -29,7 +29,6 @@ export type {
ThreadForkOutput,
ThreadId,
ThreadListItem,
ThreadStatus,
ThreadStepsOutput,
ThreadsIndex,
WorkflowConfig,
+1 -11
View File
@@ -76,27 +76,17 @@ export type ModeratorContext = {
// ── 4.5 CLI 输出 ────────────────────────────────────────────────────
/** Thread status — unified status representation */
export type ThreadStatus = "idle" | "running" | "completed" | "cancelled";
/** uwf thread start */
export type StartOutput = {
workflow: CasRef;
thread: ThreadId;
};
/**
* Output from thread show and thread exec commands.
*
* @property status - Current thread status (idle/running/completed/cancelled)
* @property done - @deprecated Use status field instead. True if thread is completed or cancelled.
* @property background - @deprecated Use status field instead. Always null in current implementation.
*/
/** uwf thread step / uwf thread show */
export type StepOutput = {
workflow: CasRef;
thread: ThreadId;
head: CasRef;
status: ThreadStatus;
done: boolean;
background: boolean | null;
};
@@ -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 };
}