Compare commits

...

23 Commits

Author SHA1 Message Date
xiaoju f61b395727 feat(cli): add currentRole field to thread show and thread list output (#571)
CI / test (pull_request) Successful in 1m9s
Add currentRole: string | null to StepOutput and ThreadListItemWithStatus.
- idle/running: derives next role via evaluate() on workflow graph
- completed/cancelled: null
-  as next role: null

Includes 9 test cases covering all status combinations and conditional routing.
2026-05-28 01:52:09 +00:00
xiaoju abc9dcfc5a fix(agent): trim leading whitespace from agent output before frontmatter extraction (#570)
CI / test (push) Successful in 1m30s
Co-authored-by: 小橘 <xiaoju@shazhou.work>
Co-committed-by: 小橘 <xiaoju@shazhou.work>
2026-05-28 00:42:22 +00:00
xiaoju 080b37c2be feat(agent): adapter stdout JSON with full metadata (#566) (#569)
CI / test (push) Successful in 1m30s
Co-authored-by: 小橘 <xiaoju@shazhou.work>
Co-committed-by: 小橘 <xiaoju@shazhou.work>
2026-05-28 00:18:57 +00:00
xiaoju 7935b73374 fix(util): remove legacy frontmatter fields next/confidence/artifacts/scope (#568)
CI / test (push) Successful in 1m36s
Co-authored-by: 小橘 <xiaoju@shazhou.work>
Co-committed-by: 小橘 <xiaoju@shazhou.work>
2026-05-28 00:11:30 +00:00
xiaoju cfa890f83c Merge PR #565: fix/553-edge-prompt-empty (#553)
CI / test (push) Successful in 1m5s
2026-05-27 22:07:53 +00:00
xiaoju 81c08ac7e2 Merge PR #564: fix/557-step-show-json-escape (#557) 2026-05-27 22:07:53 +00:00
xiaoju fdcfcc7eba Merge PR #563: fix/559-thread-show-status (#559) 2026-05-27 22:07:53 +00:00
xiaoju 4972f99ca0 Merge PR #562: fix/561-thread-start-cwd-option (#561) 2026-05-27 22:07:52 +00:00
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
xiaoju d8cba5eea0 test(cli): add JSON escaping tests for step show output (#557)
CI / test (pull_request) Successful in 1m38s
Add comprehensive tests verifying that `uwf step show` produces valid
JSON output even when step detail nodes contain control characters
(newlines, tabs, carriage returns, etc.) in tool call args and content
fields.

Tests cover:
- Basic control characters (newlines, tabs, CR+LF)
- Backslashes and quotes
- Unicode control characters (U+0001-U+001F)
- Nested CAS refs with control characters
- Large steps with multiple tool calls
- Empty/null values
- YAML output format (unaffected by escaping)

The tests confirm that JSON.stringify() already handles control
character escaping correctly when serializing JavaScript objects
to JSON. No code changes needed - these tests serve as regression
guards to ensure the behavior remains correct.

Fixes #557

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-27 16:53:07 +00:00
xiaoju d9f7648fdd feat(cli): add status field to thread show output
CI / test (pull_request) Successful in 1m30s
- Add ThreadStatus type to workflow-protocol
- Update StepOutput type to include status field alongside deprecated done/background fields
- Implement status computation in cmdThreadShow (idle/running/completed/cancelled)
- Update cmdThreadStepOnce to include status in return values
- Add comprehensive test suite for thread show status scenarios

Fixes #559

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-27 16:31:08 +00:00
xiaoju a2e9dd9785 feat(cli): add --cwd option to thread start command
CI / test (pull_request) Successful in 1m41s
Exposes the existing cwd parameter from cmdThreadStart to the CLI layer,
allowing users to specify a custom working directory for thread execution.

- Added --cwd <path> option to uwf thread start
- Option defaults to process.cwd() when not provided
- Added comprehensive test suite with 4 test cases
- All 328 tests in cli-workflow package pass

Fixes #561
2026-05-27 16:14:48 +00:00
xiaoju 3b498069b6 Merge PR #560: feat(workflow): add thread/edge location support (#558)
CI / test (push) Successful in 2m16s
2026-05-27 15:54:31 +00:00
xiaoju 984d93a6f5 feat(workflow): add thread/edge location support (#558)
CI / test (pull_request) Successful in 3m43s
Implement thread-level and edge-level working directory management:

- Thread-level cwd (required, defaults to process.cwd())
  - Captured at uwf thread start time
  - Stored in StartNodePayload
  - Inherited by all steps unless overridden

- Edge-level location (optional, supports mustache templates)
  - New location: string | null field on Target type
  - Resolved by moderator using previous step's output
  - Example: location: "{{{repoPath}}}"

- Step audit trail
  - Each StepNodePayload records actual cwd where agent executed

Changes:
- workflow-protocol: Add cwd to StartNodePayload & StepRecord, location to Target
- cli-workflow: Thread start captures cwd, moderator resolves location, step execution uses resolved cwd
- workflow-util-agent: Expose cwd in agent context

Tests:
- Protocol type tests (3 scenarios)
- Moderator location resolution tests (5 scenarios)
- Thread-location integration tests (3 scenarios)

All tests pass. Build successful. Backward compatible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-27 15:24:45 +00:00
xiaonuo 2274de29c3 Merge pull request 'fix(cli): mask apiKey in config list (#531)' (#556) from fix/531-config-mask-apikey into main
CI / test (push) Successful in 1m4s
2026-05-27 03:45:51 +00:00
xiaonuo 911cbf2a8a Merge pull request 'feat(cli): add agentOverrides and modelOverrides to config key validation' (#554) from fix/532-config-key-validation into main
CI / test (push) Successful in 1m9s
2026-05-27 03:45:47 +00:00
xiaoju 09a5da2df2 fix(cli): biome format config.test.ts
CI / test (pull_request) Successful in 1m13s
2026-05-27 01:52:44 +00:00
xiaoju e4c228d36e feat(cli): add agentOverrides and modelOverrides to config key validation (#532)
CI / test (pull_request) Successful in 1m1s
- Add agentOverrides (minDepth 3) and modelOverrides (minDepth 2) to VALID_CONFIG_KEYS
- Support per-key minDepth instead of hardcoded 3
- No knownFields for either key (sub-keys are user-defined)
- Add 5 new tests covering valid/invalid paths for both keys

小橘 <xiaoju@shazhou.work>
2026-05-27 01:50:50 +00:00
xiaoju f8de0e913b test(cli): add edge-case tests for maskApiKeys (#531)
- non-provider apiKey fields not masked (scope check)
- empty provider object handled
- null apiKey handled
- grep check for no legacy apiKeyEnv references

小橘 <xiaoju@shazhou.work>
2026-05-27 01:50:36 +00:00
xiaonuo cb97507e9a Merge pull request 'fix(hermes): add engines.bun, document adapter pattern (#551)' (#552) from fix/551-hermes-bin-engines into main
CI / test (push) Successful in 1m9s
CI / test (pull_request) Successful in 1m6s
2026-05-27 01:45:10 +00:00
xiaoju 4b442bb251 fix(hermes): sort imports in test file for biome compliance
CI / test (pull_request) Successful in 1m8s
2026-05-27 01:35:19 +00:00
xiaoju ac53128ff7 fix(hermes): add engines.bun, document adapter pattern (#551)
- Add engines.bun >= 1.0.0 to workflow-agent-hermes package.json
- Update README to explain uwf-hermes is an adapter, not hermes itself
- Update uwf setup --agent help text to mention adapter concept
- Add tests for engines field, shebang, and adapter docs
- Patch uncaged-workflow-cli skill with Agent Adapters section
2026-05-27 01:33:52 +00:00
xiaomo 607366c469 Merge pull request 'feat: add adapter skill + fix commit scope' (#550) from fix/549-commit-scope into main
CI / test (push) Successful in 1m26s
2026-05-26 17:26:47 +00:00
41 changed files with 2700 additions and 502 deletions
@@ -0,0 +1,174 @@
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { registerUwfSchemas } from "../schemas.js";
import { saveThreadsIndex } from "../store.js";
// ── schemas ──────────────────────────────────────────────────────────────────
const OUTPUT_SCHEMA = {
type: "object" as const,
properties: {
$status: { type: "string" as const, enum: ["done", "failed"] },
result: { type: "string" as const },
},
required: ["$status"],
additionalProperties: false,
};
// ── fixture ──────────────────────────────────────────────────────────────────
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-roundtrip-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
describe("C1: adapter JSON round-trip integration", () => {
test("mock agent outputs JSON, CLI parses it and updates thread head in CAS", async () => {
// 1. Set up CAS store with workflow, start node, and output schema
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const outputSchemaHash = await putSchema(store, OUTPUT_SCHEMA);
const workflowHash = await store.put(schemas.workflow, {
name: "test-roundtrip",
description: "roundtrip integration test",
roles: {
worker: {
description: "Worker role",
goal: "Do work",
capabilities: [],
procedure: "work",
output: "result",
frontmatter: outputSchemaHash,
},
},
graph: {
$START: { _: { role: "worker", prompt: "Do the work", location: null } },
worker: { done: { role: "$END", prompt: "completed", location: null } },
},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test round-trip task",
});
const threadId = "01ROUNDTRIPTEST0000000000" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: startHash });
// 2. Pre-create CAS nodes that the mock agent would produce
const outputHash = await store.put(outputSchemaHash, {
$status: "done",
result: "test-ok",
});
// Use text schema for detail (simple placeholder)
const detailHash = await store.put(schemas.text, "mock detail");
const startedAtMs = 1716600000000;
const completedAtMs = 1716600001500;
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-mock",
edgePrompt: "Do the work",
startedAtMs,
completedAtMs,
cwd: tmpDir,
});
// 3. Create a minimal mock agent shell script that just outputs JSON
// The step node is already in CAS — the agent just needs to print the JSON line
const mockAgentPath = join(tmpDir, "mock-agent.sh");
const adapterJson = JSON.stringify({
stepHash,
detailHash,
role: "worker",
frontmatter: { $status: "done", result: "test-ok" },
body: "",
startedAtMs,
completedAtMs,
});
await writeFile(mockAgentPath, `#!/bin/sh\necho '${adapterJson}'\n`, { mode: 0o755 });
// 4. Write config.yaml
const configPath = join(tmpDir, "config.yaml");
await writeFile(
configPath,
`defaultAgent: uwf-hermes\ndefaultModel: test-model\nagentOverrides: null\nagents: {}\nproviders: {}\nmodels: {}\n`,
);
// 5. Run CLI with agent override pointing to our mock
const cliPath = join(import.meta.dirname, "..", "cli.js");
let stdout: string;
let stderr: string;
let exitCode: number;
try {
stdout = execFileSync(
"bun",
["run", cliPath, "thread", "exec", threadId, "--agent", mockAgentPath],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, WORKFLOW_STORAGE_ROOT: tmpDir },
cwd: tmpDir,
timeout: 30000,
},
);
stderr = "";
exitCode = 0;
} catch (e: unknown) {
const err = e as NodeJS.ErrnoException & {
stdout?: string;
stderr?: string;
status?: number;
};
stdout = err.stdout ?? "";
stderr = err.stderr ?? "";
exitCode = err.status ?? 1;
}
// 6. Verify
if (exitCode !== 0) {
throw new Error(`CLI exited with code ${exitCode}\nstdout: ${stdout}\nstderr: ${stderr}`);
}
// Parse CLI output
const cliOutput = JSON.parse(stdout.trim());
expect(cliOutput).toHaveProperty("thread", threadId);
expect(cliOutput).toHaveProperty("head", stepHash);
expect(cliOutput.head).toMatch(/^[0-9A-HJ-NP-TV-Z]{13}$/);
// Verify the CAS step node exists and has correct metadata
const storeAfter = createFsStore(casDir);
const stepNode = storeAfter.get(cliOutput.head as CasRef);
expect(stepNode).not.toBeNull();
const payload = stepNode!.payload as StepNodePayload;
expect(payload.role).toBe("worker");
expect(payload.agent).toBe("uwf-mock");
expect(payload.startedAtMs).toBe(1716600000000);
expect(payload.completedAtMs).toBe(1716600001500);
expect(payload.output).toBe(outputHash);
expect(payload.detail).toBe(detailHash);
});
});
@@ -143,6 +143,44 @@ defaultModel: default
const masked = maskApiKeys(config); const masked = maskApiKeys(config);
expect(masked).toEqual(config); expect(masked).toEqual(config);
}); });
test("does not mask non-provider apiKey fields", () => {
const config = {
apiKey: "root-level-key",
providers: {
dashscope: { apiKey: "sk-secret" },
},
models: {
default: { provider: "dashscope" },
},
};
const masked = maskApiKeys(config);
// Root-level apiKey should NOT be masked
expect(masked.apiKey).toBe("root-level-key");
// Provider apiKey SHOULD be masked
const providers = masked.providers as Record<string, Record<string, unknown>>;
expect(providers.dashscope.apiKey).toBe("***MASKED***");
});
test("handles empty provider object", () => {
const config = {
providers: { dashscope: {} },
};
const masked = maskApiKeys(config);
expect(masked).toEqual({ providers: { dashscope: {} } });
});
test("handles provider with null apiKey", () => {
const config = {
providers: {
dashscope: { apiKey: null, baseUrl: "https://example.com" },
},
};
const masked = maskApiKeys(config);
const providers = masked.providers as Record<string, Record<string, unknown>>;
expect(providers.dashscope.apiKey).toBe("***MASKED***");
expect(providers.dashscope.baseUrl).toBe("https://example.com");
});
}); });
}); });
@@ -618,5 +656,82 @@ defaultModel: default
rmSync(tempDir, { recursive: true, force: true }); rmSync(tempDir, { recursive: true, force: true });
} }
}); });
test("agentOverrides — accepts valid 3-segment path", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await cmdConfigSet(tempDir, "agentOverrides.solve-issue.planner", "claude-code");
const value = await cmdConfigGet(tempDir, "agentOverrides.solve-issue.planner");
expect(value).toBe("claude-code");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("agentOverrides — rejects incomplete path (2 segments)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "agentOverrides.solve-issue", "hermes")).rejects.toThrow(
/incomplete path|must specify a field/i,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("modelOverrides — accepts valid 2-segment path", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await cmdConfigSet(tempDir, "modelOverrides.extract", "gpt4");
const value = await cmdConfigGet(tempDir, "modelOverrides.extract");
expect(value).toBe("gpt4");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("modelOverrides — rejects incomplete path (1 segment only)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "modelOverrides", "gpt4")).rejects.toThrow(
/incomplete path|must specify a field/i,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects unknown top-level key (regression)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "randomKey", "value")).rejects.toThrow(
/Unknown config key/,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
describe("no legacy apiKeyEnv references", () => {
test("config.ts has no references to apiKeyEnv", () => {
const configSource = readFileSync(join(__dirname, "..", "commands", "config.ts"), "utf8");
expect(configSource).not.toContain("apiKeyEnv");
});
test("config.test.ts has no references to apiKeyEnv (except this test)", () => {
const testSource = readFileSync(__filename, "utf8");
// Remove this test block's own mentions before checking
const withoutThisTest = testSource.replace(
/describe\("no legacy apiKeyEnv references"[\s\S]*$/,
"",
);
expect(withoutThisTest).not.toContain("apiKeyEnv");
});
}); });
}); });
@@ -0,0 +1,442 @@
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { putSchema } from "@uncaged/json-cas";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { createMarker, deleteMarker } from "../background/index.js";
import { cmdThreadList, cmdThreadShow, cmdThreadStart } from "../commands/thread.js";
import {
appendThreadHistory,
createUwfStore,
loadThreadsIndex,
saveThreadsIndex,
} from "../store.js";
const OUTPUT_SCHEMA = {
type: "object" as const,
properties: {
$status: { type: "string" as const },
},
};
const SIMPLE_WORKFLOW_YAML = `
name: test-current-role
description: Test workflow for currentRole
roles:
roleA:
description: First role
goal: Do A
capabilities: ["coding"]
procedure: Do A
output: |
$status: "ready"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string, enum: ["ready", "not-ready"] }
roleB:
description: Second role
goal: Do B
capabilities: ["coding"]
procedure: Do B
output: |
$status: "done"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string }
graph:
$START:
_:
role: roleA
prompt: "Do A"
location: null
roleA:
ready:
role: roleB
prompt: "Do B"
location: null
not-ready:
role: roleA
prompt: "Try again"
location: null
roleB:
_:
role: $END
prompt: "Done"
location: null
`;
const CONDITIONAL_WORKFLOW_YAML = `
name: test-conditional-role
description: Conditional routing workflow
roles:
roleA:
description: First role
goal: Do A
capabilities: ["coding"]
procedure: Do A
output: |
$status: "pass"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string, enum: ["pass", "fail"] }
roleB:
description: Pass role
goal: Do B
capabilities: ["coding"]
procedure: Do B
output: |
$status: "done"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string }
roleC:
description: Fail role
goal: Do C
capabilities: ["coding"]
procedure: Do C
output: |
$status: "done"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string }
graph:
$START:
_:
role: roleA
prompt: "Do A"
location: null
roleA:
pass:
role: roleB
prompt: "Do B (pass)"
location: null
fail:
role: roleC
prompt: "Do C (fail)"
location: null
roleB:
_:
role: $END
prompt: "Done"
location: null
roleC:
_:
role: $END
prompt: "Done"
location: null
`;
const SINGLE_ROLE_WORKFLOW_YAML = `
name: test-single-role
description: Single role that goes to END
roles:
worker:
description: Worker
goal: Work
capabilities: ["coding"]
procedure: Work
output: |
$status: "done"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string }
graph:
$START:
_:
role: worker
prompt: "Work"
location: null
worker:
_:
role: $END
prompt: "Done"
location: null
`;
/** Helper: insert a completed step node after the current head. */
async function insertStepNode(
storageRoot: string,
threadId: ThreadId,
role: string,
outputPayload: Record<string, unknown>,
): Promise<void> {
const uwf = await createUwfStore(storageRoot);
const index = await loadThreadsIndex(storageRoot);
const head = index[threadId];
if (head === undefined) throw new Error(`thread ${threadId} not in index`);
const outputSchemaHash = await putSchema(uwf.store, OUTPUT_SCHEMA);
const outputHash = await uwf.store.put(outputSchemaHash, outputPayload);
// Use text schema for detail (simple placeholder)
const detailHash = await uwf.store.put(uwf.schemas.text, "detail-placeholder");
// Resolve start hash from head
const headNode = uwf.store.get(head);
if (headNode === null) throw new Error(`head ${head} not found`);
const isStart = headNode.type === uwf.schemas.startNode;
const startHash = isStart ? head : (headNode.payload as { start: CasRef }).start;
const stepHash = (await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: isStart ? null : head,
role,
prompt: `Do ${role}`,
output: outputHash,
detail: detailHash,
})) as CasRef;
index[threadId] = stepHash;
await saveThreadsIndex(storageRoot, index);
}
describe("currentRole field", () => {
let tmpDir: string;
let storageRoot: string;
async function setup() {
tmpDir = join(
tmpdir(),
`uwf-test-current-role-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
}
async function teardown() {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true });
}
}
// T1: idle at start — currentRole = first role from graph
test("thread show — idle at start returns first role as currentRole", async () => {
await setup();
try {
const wf = join(tmpDir, "test-current-role.yaml");
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
const { thread } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
const result = await cmdThreadShow(storageRoot, thread as ThreadId);
expect(result.status).toBe("idle");
expect(result.currentRole).toBe("roleA");
} finally {
await teardown();
}
});
// T2: idle after one step — currentRole = next role
test("thread show — idle after step returns next role as currentRole", async () => {
await setup();
try {
const wf = join(tmpDir, "test-current-role.yaml");
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
const { thread } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
await insertStepNode(storageRoot, thread as ThreadId, "roleA", { $status: "ready" });
const result = await cmdThreadShow(storageRoot, thread as ThreadId);
expect(result.status).toBe("idle");
expect(result.currentRole).toBe("roleB");
} finally {
await teardown();
}
});
// T3: completed → currentRole = null
test("thread show — completed thread returns null currentRole", async () => {
await setup();
try {
const wf = join(tmpDir, "test-current-role.yaml");
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
const { thread, workflow } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
const tid = thread as ThreadId;
const index = await loadThreadsIndex(storageRoot);
const head = index[tid]!;
delete index[tid];
await saveThreadsIndex(storageRoot, index);
await appendThreadHistory(storageRoot, {
thread: tid,
workflow,
head,
completedAt: Date.now(),
reason: "completed",
});
const result = await cmdThreadShow(storageRoot, tid);
expect(result.status).toBe("completed");
expect(result.currentRole).toBe(null);
} finally {
await teardown();
}
});
// T4: cancelled → currentRole = null
test("thread show — cancelled thread returns null currentRole", async () => {
await setup();
try {
const wf = join(tmpDir, "test-current-role.yaml");
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
const { thread, workflow } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
const tid = thread as ThreadId;
const index = await loadThreadsIndex(storageRoot);
const head = index[tid]!;
delete index[tid];
await saveThreadsIndex(storageRoot, index);
await appendThreadHistory(storageRoot, {
thread: tid,
workflow,
head,
completedAt: Date.now(),
reason: "cancelled",
});
const result = await cmdThreadShow(storageRoot, tid);
expect(result.status).toBe("cancelled");
expect(result.currentRole).toBe(null);
} finally {
await teardown();
}
});
// T5: running → currentRole = role being executed
test("thread show — running thread returns current role", async () => {
await setup();
try {
const wf = join(tmpDir, "test-current-role.yaml");
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
const { thread, workflow } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
const tid = thread as ThreadId;
await createMarker(storageRoot, {
thread: tid,
workflow,
pid: process.pid,
startedAt: Date.now(),
});
try {
const result = await cmdThreadShow(storageRoot, tid);
expect(result.status).toBe("running");
expect(result.currentRole).toBe("roleA");
} finally {
await deleteMarker(storageRoot, tid);
}
} finally {
await teardown();
}
});
// T6: thread list — mixed statuses with correct currentRole
test("thread list — returns correct currentRole for each status", async () => {
await setup();
try {
const wf = join(tmpDir, "test-current-role.yaml");
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
// idle thread
const idle = await cmdThreadStart(storageRoot, wf, "idle", tmpDir);
const idleId = idle.thread as ThreadId;
// completed thread
const comp = await cmdThreadStart(storageRoot, wf, "completed", tmpDir);
const compId = comp.thread as ThreadId;
const index = await loadThreadsIndex(storageRoot);
const compHead = index[compId]!;
delete index[compId];
await saveThreadsIndex(storageRoot, index);
await appendThreadHistory(storageRoot, {
thread: compId,
workflow: comp.workflow,
head: compHead,
completedAt: Date.now(),
reason: "completed",
});
const list = await cmdThreadList(storageRoot, null, null, null, 0, 100);
const idleItem = list.find((i) => i.thread === idleId);
expect(idleItem).toBeDefined();
expect(idleItem!.currentRole).toBe("roleA");
const compItem = list.find((i) => i.thread === compId);
expect(compItem).toBeDefined();
expect(compItem!.currentRole).toBe(null);
} finally {
await teardown();
}
});
// T7: thread list — idle at start has correct currentRole
test("thread list — idle thread at start has correct currentRole", async () => {
await setup();
try {
const wf = join(tmpDir, "test-current-role.yaml");
await writeFile(wf, SIMPLE_WORKFLOW_YAML, "utf8");
const { thread } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
const list = await cmdThreadList(storageRoot, null, null, null, 0, 100);
const item = list.find((i) => i.thread === (thread as ThreadId));
expect(item).toBeDefined();
expect(item!.currentRole).toBe("roleA");
} finally {
await teardown();
}
});
// T8: conditional routing — $status=pass vs fail
test("thread show — conditional routing selects correct next role", async () => {
await setup();
try {
const wf = join(tmpDir, "test-conditional-role.yaml");
await writeFile(wf, CONDITIONAL_WORKFLOW_YAML, "utf8");
// pass path
const t1 = await cmdThreadStart(storageRoot, wf, "pass test", tmpDir);
await insertStepNode(storageRoot, t1.thread as ThreadId, "roleA", { $status: "pass" });
const r1 = await cmdThreadShow(storageRoot, t1.thread as ThreadId);
expect(r1.currentRole).toBe("roleB");
// fail path
const t2 = await cmdThreadStart(storageRoot, wf, "fail test", tmpDir);
await insertStepNode(storageRoot, t2.thread as ThreadId, "roleA", { $status: "fail" });
const r2 = await cmdThreadShow(storageRoot, t2.thread as ThreadId);
expect(r2.currentRole).toBe("roleC");
} finally {
await teardown();
}
});
// T9: next role is $END → currentRole = null
test("thread show — when next is $END, currentRole is null", async () => {
await setup();
try {
const wf = join(tmpDir, "test-single-role.yaml");
await writeFile(wf, SINGLE_ROLE_WORKFLOW_YAML, "utf8");
const { thread } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
// worker → _ maps to $END
await insertStepNode(storageRoot, thread as ThreadId, "worker", {});
const result = await cmdThreadShow(storageRoot, thread as ThreadId);
expect(result.currentRole).toBe(null);
} finally {
await teardown();
}
});
});
@@ -5,17 +5,17 @@ import { evaluate } from "../moderator/evaluate.js";
const solveIssueGraph: WorkflowPayload["graph"] = { const solveIssueGraph: WorkflowPayload["graph"] = {
$START: { $START: {
_: { role: "planner", prompt: "Start planning from the issue in the task." }, _: { role: "planner", prompt: "Start planning from the issue in the task.", location: null },
}, },
planner: { planner: {
_: { role: "developer", prompt: "Implement the plan: {{plan}}" }, _: { role: "developer", prompt: "Implement the plan: {{plan}}", location: null },
}, },
developer: { developer: {
_: { role: "reviewer", prompt: "Review the changes: {{summary}}" }, _: { role: "reviewer", prompt: "Review the changes: {{summary}}", location: null },
}, },
reviewer: { reviewer: {
approved: { role: "$END", prompt: "Done." }, approved: { role: "$END", prompt: "Done.", location: null },
rejected: { role: "developer", prompt: "Fix: {{comments}}" }, rejected: { role: "developer", prompt: "Fix: {{comments}}", location: null },
}, },
}; };
@@ -24,7 +24,11 @@ describe("evaluate", () => {
const result = evaluate(solveIssueGraph, "$START", { $status: "_" }); const result = evaluate(solveIssueGraph, "$START", { $status: "_" });
expect(result).toEqual({ expect(result).toEqual({
ok: true, ok: true,
value: { role: "planner", prompt: "Start planning from the issue in the task." }, value: {
role: "planner",
prompt: "Start planning from the issue in the task.",
location: null,
},
}); });
}); });
@@ -35,7 +39,7 @@ describe("evaluate", () => {
}); });
expect(result).toEqual({ expect(result).toEqual({
ok: true, ok: true,
value: { role: "developer", prompt: "Fix: missing tests" }, value: { role: "developer", prompt: "Fix: missing tests", location: null },
}); });
}); });
@@ -43,7 +47,7 @@ describe("evaluate", () => {
const result = evaluate(solveIssueGraph, "reviewer", { $status: "approved" }); const result = evaluate(solveIssueGraph, "reviewer", { $status: "approved" });
expect(result).toEqual({ expect(result).toEqual({
ok: true, ok: true,
value: { role: "$END", prompt: "Done." }, value: { role: "$END", prompt: "Done.", location: null },
}); });
}); });
@@ -70,7 +74,11 @@ describe("evaluate", () => {
}); });
expect(result).toEqual({ expect(result).toEqual({
ok: true, ok: true,
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" }, value: {
role: "developer",
prompt: "Implement the plan: Add auth middleware",
location: null,
},
}); });
}); });
@@ -81,14 +89,14 @@ describe("evaluate", () => {
}); });
expect(result).toEqual({ expect(result).toEqual({
ok: true, ok: true,
value: { role: "developer", prompt: 'Fix: use <T> & "Result<T, E>" types' }, value: { role: "developer", prompt: 'Fix: use <T> & "Result<T, E>" types', location: null },
}); });
}); });
test("triple mustache also works for unescaped output", () => { test("triple mustache also works for unescaped output", () => {
const graph: Record<string, Record<string, Target>> = { const graph: Record<string, Record<string, Target>> = {
reviewer: { reviewer: {
_: { role: "developer", prompt: "Fix: {{{comments}}}" }, _: { role: "developer", prompt: "Fix: {{{comments}}}", location: null },
}, },
}; };
const result = evaluate(graph, "reviewer", { const result = evaluate(graph, "reviewer", {
@@ -97,7 +105,7 @@ describe("evaluate", () => {
}); });
expect(result).toEqual({ expect(result).toEqual({
ok: true, ok: true,
value: { role: "developer", prompt: "Fix: <script>alert(1)</script>" }, value: { role: "developer", prompt: "Fix: <script>alert(1)</script>", location: null },
}); });
}); });
@@ -107,7 +115,11 @@ describe("evaluate", () => {
}); });
expect(result).toEqual({ expect(result).toEqual({
ok: true, ok: true,
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" }, value: {
role: "developer",
prompt: "Implement the plan: Add auth middleware",
location: null,
},
}); });
}); });
@@ -117,6 +129,7 @@ describe("evaluate", () => {
_: { _: {
role: "developer", role: "developer",
prompt: "Address: {{review.comments}}", prompt: "Address: {{review.comments}}",
location: null,
}, },
}, },
}; };
@@ -126,7 +139,7 @@ describe("evaluate", () => {
}); });
expect(result).toEqual({ expect(result).toEqual({
ok: true, ok: true,
value: { role: "developer", prompt: "Address: refactor the handler" }, value: { role: "developer", prompt: "Address: refactor the handler", location: null },
}); });
}); });
}); });
@@ -0,0 +1,100 @@
import { describe, expect, test } from "vitest";
/**
* B-group tests: validate JSON parsing logic used by spawnAgent.
*
* We test the parsing logic inline since spawnAgent is a private function.
* These tests verify the contract: last line of stdout must be valid JSON
* with a valid stepHash CasRef.
*/
const CASREF_PATTERN = /^[0-9A-HJ-NP-TV-Z]{13}$/;
function isCasRef(s: string): boolean {
return CASREF_PATTERN.test(s);
}
type AdapterOutput = {
stepHash: string;
detailHash: string;
role: string;
frontmatter: Record<string, unknown>;
body: string;
startedAtMs: number;
completedAtMs: number;
};
function parseAgentStdout(stdout: string): AdapterOutput {
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
throw new Error(`agent stdout last line is not valid JSON: ${line || "(empty)"}`);
}
const obj = parsed as Record<string, unknown>;
if (
typeof obj !== "object" ||
obj === null ||
typeof obj.stepHash !== "string" ||
!isCasRef(obj.stepHash as string)
) {
throw new Error(`agent stdout JSON missing valid stepHash: ${line}`);
}
return obj as unknown as AdapterOutput;
}
const VALID_OUTPUT: AdapterOutput = {
stepHash: "0123456789ABC",
detailHash: "DEFGH12345678",
role: "planner",
frontmatter: { $status: "ready", plan: "somehash" },
body: "Plan body",
startedAtMs: 1000,
completedAtMs: 2000,
};
describe("spawnAgent JSON parsing", () => {
test("B1. parses valid JSON from agent stdout", () => {
const stdout = JSON.stringify(VALID_OUTPUT) + "\n";
const result = parseAgentStdout(stdout);
expect(result.stepHash).toBe("0123456789ABC");
expect(result.detailHash).toBe("DEFGH12345678");
expect(result.role).toBe("planner");
expect(result.frontmatter).toEqual({ $status: "ready", plan: "somehash" });
expect(result.body).toBe("Plan body");
expect(result.startedAtMs).toBe(1000);
expect(result.completedAtMs).toBe(2000);
});
test("B2. extracts stepHash for head pointer", () => {
const stdout = JSON.stringify(VALID_OUTPUT) + "\n";
const result = parseAgentStdout(stdout);
expect(result.stepHash).toBe("0123456789ABC");
expect(isCasRef(result.stepHash)).toBe(true);
});
test("B3. handles debug lines before JSON", () => {
const debugLines = "[debug] loading context...\n[debug] running agent...\n";
const stdout = debugLines + JSON.stringify(VALID_OUTPUT) + "\n";
const result = parseAgentStdout(stdout);
expect(result.stepHash).toBe("0123456789ABC");
});
test("B4. rejects non-JSON last line", () => {
const stdout = "not-json-at-all\n";
expect(() => parseAgentStdout(stdout)).toThrow("not valid JSON");
});
test("B5. rejects JSON missing stepHash", () => {
const incomplete = { detailHash: "DEFGH12345678", role: "planner" };
const stdout = JSON.stringify(incomplete) + "\n";
expect(() => parseAgentStdout(stdout)).toThrow("missing valid stepHash");
});
test("B6. rejects JSON with invalid stepHash", () => {
const bad = { ...VALID_OUTPUT, stepHash: "not-a-hash" };
const stdout = JSON.stringify(bad) + "\n";
expect(() => parseAgentStdout(stdout)).toThrow("missing valid stepHash");
});
});
@@ -0,0 +1,363 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, type Hash, type JSONSchema, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, StepNodePayload } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdStepShow } from "../commands/step.js";
import { formatOutput } from "../format.js";
import { registerUwfSchemas } from "../schemas.js";
const TURN_SCHEMA: JSONSchema = {
title: "test-turn",
type: "object",
required: ["index", "role", "content"],
properties: {
index: { type: "integer" },
role: { type: "string", enum: ["assistant", "tool"] },
content: { type: "string" },
toolCalls: {
anyOf: [
{
type: "array",
items: {
type: "object",
required: ["name", "args"],
properties: {
name: { type: "string" },
args: { type: "string" },
},
additionalProperties: false,
},
},
{ type: "null" },
],
},
},
additionalProperties: false,
};
const DETAIL_SCHEMA: JSONSchema = {
title: "test-detail",
type: "object",
required: ["turns"],
properties: {
turns: {
type: "array",
items: { type: "string", format: "cas_ref" },
},
},
additionalProperties: false,
};
type TestSetup = {
store: ReturnType<typeof createFsStore>;
schemas: {
workflow: Hash;
startNode: Hash;
stepNode: Hash;
text: Hash;
};
turnType: Hash;
detailType: Hash;
};
async function setupTest(casDir: string): Promise<TestSetup> {
const store = createFsStore(casDir);
await bootstrap(store);
const schemas = await registerUwfSchemas(store);
const [turnType, detailType] = await Promise.all([
putSchema(store, TURN_SCHEMA),
putSchema(store, DETAIL_SCHEMA),
]);
return { store, schemas, turnType, detailType };
}
async function createTestStep(
setup: TestSetup,
turnPayloads: Array<{
index: number;
role: string;
content: string;
toolCalls: Array<{ name: string; args: string }> | null;
}>,
): Promise<CasRef> {
const { store, schemas, turnType, detailType } = setup;
// Create turn nodes
const turnHashes: CasRef[] = [];
for (const payload of turnPayloads) {
const turnHash = await store.put(turnType, payload);
turnHashes.push(turnHash);
}
// Create detail node
const detailHash = await store.put(detailType, { turns: turnHashes });
// Create dummy start node
const startHash = await store.put(schemas.startNode, {
workflow: "0000000000000" as CasRef,
prompt: "test prompt",
cwd: "/tmp",
});
// Create dummy output node
const outputHash = await store.put(schemas.text, { $status: "done" });
// Create step node
const stepPayload: StepNodePayload = {
prev: null,
start: startHash,
role: "test-role",
agent: "test-agent",
output: outputHash,
detail: detailHash,
edgePrompt: "",
startedAtMs: Date.now(),
completedAtMs: Date.now() + 1000,
cwd: "/tmp",
};
return store.put(schemas.stepNode, stepPayload);
}
describe("cmdStepShow JSON serialization", () => {
let testDir: string;
let casDir: string;
beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), "uwf-test-"));
casDir = join(testDir, "cas");
await mkdir(casDir, { recursive: true });
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
});
test("escapes newlines in tool call args", async () => {
const setup = await setupTest(casDir);
const stepHash = await createTestStep(setup, [
{
index: 0,
role: "assistant",
content: "Running command",
toolCalls: [
{
name: "Bash",
args: "echo 'line1'\necho 'line2'",
},
],
},
]);
const result = await cmdStepShow(testDir, stepHash);
const jsonOutput = formatOutput(result, "json");
expect(() => JSON.parse(jsonOutput)).not.toThrow();
expect(jsonOutput).toContain("\\n");
const parsed = JSON.parse(jsonOutput);
expect(parsed.turns[0].toolCalls[0].args).toContain("\n");
});
test("escapes tabs in tool call args", async () => {
const setup = await setupTest(casDir);
const stepHash = await createTestStep(setup, [
{
index: 0,
role: "assistant",
content: "",
toolCalls: [
{
name: "Bash",
args: "cat <<EOF\nfield1\tfield2\tfield3\nEOF",
},
],
},
]);
const result = await cmdStepShow(testDir, stepHash);
const jsonOutput = formatOutput(result, "json");
expect(() => JSON.parse(jsonOutput)).not.toThrow();
expect(jsonOutput).toContain("\\t");
});
test("escapes carriage returns", async () => {
const setup = await setupTest(casDir);
const stepHash = await createTestStep(setup, [
{
index: 0,
role: "assistant",
content: "Committing changes",
toolCalls: [
{
name: "Bash",
args: 'git commit -m "First line\r\nSecond line"',
},
],
},
]);
const result = await cmdStepShow(testDir, stepHash);
const jsonOutput = formatOutput(result, "json");
expect(() => JSON.parse(jsonOutput)).not.toThrow();
expect(jsonOutput).toContain("\\r\\n");
});
test("escapes backslashes and quotes", async () => {
const setup = await setupTest(casDir);
const stepHash = await createTestStep(setup, [
{
index: 0,
role: "assistant",
content: "",
toolCalls: [
{
name: "Bash",
args: 'echo "He said \\"hello\\""',
},
],
},
]);
const result = await cmdStepShow(testDir, stepHash);
const jsonOutput = formatOutput(result, "json");
expect(() => JSON.parse(jsonOutput)).not.toThrow();
const parsed = JSON.parse(jsonOutput);
expect(parsed.turns).toBeDefined();
});
test("handles Unicode control characters", async () => {
const setup = await setupTest(casDir);
const stepHash = await createTestStep(setup, [
{
index: 0,
role: "assistant",
content: "",
toolCalls: [
{
name: "Bash",
args: "echo '\u0001\u001F'",
},
],
},
]);
const result = await cmdStepShow(testDir, stepHash);
const jsonOutput = formatOutput(result, "json");
expect(() => JSON.parse(jsonOutput)).not.toThrow();
});
test("handles nested CAS refs with control characters", async () => {
const setup = await setupTest(casDir);
const stepHash = await createTestStep(setup, [
{
index: 0,
role: "assistant",
content: "First turn\nwith newline",
toolCalls: [
{
name: "Bash",
args: "cmd1\nline2",
},
],
},
{
index: 1,
role: "assistant",
content: "Second turn\twith tab",
toolCalls: null,
},
]);
const result = await cmdStepShow(testDir, stepHash);
const jsonOutput = formatOutput(result, "json");
expect(() => JSON.parse(jsonOutput)).not.toThrow();
const parsed = JSON.parse(jsonOutput);
expect(parsed.turns).toHaveLength(2);
});
test("YAML output format is unaffected", async () => {
const setup = await setupTest(casDir);
const stepHash = await createTestStep(setup, [
{
index: 0,
role: "assistant",
content: "Running command",
toolCalls: [
{
name: "Bash",
args: "echo 'line1'\necho 'line2'",
},
],
},
]);
const result = await cmdStepShow(testDir, stepHash);
const yamlOutput = formatOutput(result, "yaml");
expect(yamlOutput).toContain("turns:");
expect(yamlOutput.length).toBeGreaterThan(0);
});
test("handles empty and null values", async () => {
const setup = await setupTest(casDir);
const stepHash = await createTestStep(setup, [
{
index: 0,
role: "assistant",
content: "",
toolCalls: null,
},
]);
const result = await cmdStepShow(testDir, stepHash);
const jsonOutput = formatOutput(result, "json");
expect(() => JSON.parse(jsonOutput)).not.toThrow();
const parsed = JSON.parse(jsonOutput);
expect(parsed.turns).toBeDefined();
});
test("handles large step with multiple tool calls", async () => {
const setup = await setupTest(casDir);
const turns = [];
for (let i = 0; i < 25; i++) {
turns.push({
index: i,
role: "assistant" as const,
content: `Turn ${i}\nwith newline`,
toolCalls: [
{
name: "Bash",
args: `command${i}\nline2\tfield${i}`,
},
{
name: "Read",
args: `/path/to/file${i}`,
},
],
});
}
const stepHash = await createTestStep(setup, turns);
const startTime = Date.now();
const result = await cmdStepShow(testDir, stepHash);
const jsonOutput = formatOutput(result, "json");
const duration = Date.now() - startTime;
expect(duration).toBeLessThan(2000);
expect(() => JSON.parse(jsonOutput)).not.toThrow();
const parsed = JSON.parse(jsonOutput);
expect(parsed.turns).toHaveLength(25);
});
});
@@ -85,6 +85,7 @@ describe("protocol types", () => {
edgePrompt: "", edgePrompt: "",
startedAtMs: 1000, startedAtMs: 1000,
completedAtMs: 2000, completedAtMs: 2000,
cwd: "/test/path",
}; };
expect(record.startedAtMs).toBe(1000); expect(record.startedAtMs).toBe(1000);
expect(record.completedAtMs).toBe(2000); expect(record.completedAtMs).toBe(2000);
@@ -239,8 +240,8 @@ describe("thread read timing", () => {
}, },
}, },
graph: { graph: {
$START: { _: { role: "worker", prompt: "go" } }, $START: { _: { role: "worker", prompt: "go", location: null } },
worker: { _: { role: "$END", prompt: "" } }, worker: { _: { role: "$END", prompt: "", location: null } },
}, },
}); });
@@ -305,8 +306,8 @@ describe("thread read timing", () => {
}, },
}, },
graph: { graph: {
$START: { _: { role: "worker", prompt: "go" } }, $START: { _: { role: "worker", prompt: "go", location: null } },
worker: { _: { role: "$END", prompt: "" } }, worker: { _: { role: "$END", prompt: "", location: null } },
}, },
}); });
@@ -0,0 +1,174 @@
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasRef, StartNodePayload, ThreadId } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { cmdThreadStart } from "../commands/thread.js";
import { createUwfStore } from "../store.js";
describe("Thread and edge location integration", () => {
let tmpDir: string;
let storageRoot: string;
async function setupTestEnv() {
tmpDir = join(tmpdir(), `uwf-test-location-${Date.now()}`);
storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
}
async function teardown() {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true });
}
}
test("thread start captures cwd in StartNode", async () => {
await setupTestEnv();
const workflowYaml = `
name: test-location
description: Test workflow for location feature
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
`;
const workflowPath = join(tmpDir, "test-location.yaml");
await writeFile(workflowPath, workflowYaml, "utf8");
const testCwd = "/test/project/path";
const result = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir, testCwd);
expect(result.thread).toBeDefined();
expect(result.workflow).toBeDefined();
// Verify StartNode has the cwd field
const uwf = await createUwfStore(storageRoot);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
const headHash = index[result.thread as ThreadId];
expect(headHash).toBeDefined();
const startNode = uwf.store.get(headHash as CasRef);
expect(startNode).not.toBe(null);
expect(startNode?.type).toBe(uwf.schemas.startNode);
const startPayload = startNode?.payload as StartNodePayload;
expect(startPayload.cwd).toBe(testCwd);
await teardown();
});
test("thread start validates cwd is absolute path", async () => {
await setupTestEnv();
const workflowYaml = `
name: test-location
description: Test workflow
roles:
planner:
description: Plans
goal: Plan
capabilities: ["planning"]
procedure: Plan
output: |
$status: "ready"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string }
graph:
$START:
_:
role: planner
prompt: "Plan"
location: null
planner:
_:
role: $END
prompt: "Done"
location: null
`;
const workflowPath = join(tmpDir, "test-location.yaml");
await writeFile(workflowPath, workflowYaml, "utf8");
// Relative path should fail (process.exit is wrapped by vitest)
await expect(
cmdThreadStart(storageRoot, workflowPath, "test", tmpDir, "relative/path"),
).rejects.toThrow();
await teardown();
});
test("thread start uses process.cwd() as default", async () => {
await setupTestEnv();
const workflowYaml = `
name: test-default-cwd
description: Test default cwd
roles:
planner:
description: Plans
goal: Plan
capabilities: ["planning"]
procedure: Plan
output: |
$status: "ready"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string }
graph:
$START:
_:
role: planner
prompt: "Plan"
location: null
planner:
_:
role: $END
prompt: "Done"
location: null
`;
const workflowPath = join(tmpDir, "test-default-cwd.yaml");
await writeFile(workflowPath, workflowYaml, "utf8");
const result = await cmdThreadStart(storageRoot, workflowPath, "test", tmpDir);
const uwf = await createUwfStore(storageRoot);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
const headHash = index[result.thread as ThreadId];
const startNode = uwf.store.get(headHash as CasRef);
const startPayload = startNode?.payload as StartNodePayload;
// Should default to process.cwd()
expect(startPayload.cwd).toBe(process.cwd());
await teardown();
});
});
@@ -0,0 +1,227 @@
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();
});
});
@@ -0,0 +1,148 @@
import { execFileSync } from "node:child_process";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasRef, StartNodePayload, ThreadId } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { cmdThreadStart } from "../commands/thread.js";
import { createUwfStore, loadThreadsIndex } from "../store.js";
describe("thread start --cwd CLI option", () => {
let tmpDir: string;
let storageRoot: string;
async function setupTestEnv() {
tmpDir = join(tmpdir(), `uwf-test-cwd-cli-${Date.now()}`);
storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
}
async function teardown() {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true });
}
}
async function createTestWorkflow(): Promise<string> {
const workflowYaml = `
name: test-cwd-cli
description: Test workflow for CLI cwd option
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
`;
const workflowPath = join(tmpDir, "test-cwd-cli.yaml");
await writeFile(workflowPath, workflowYaml, "utf8");
return workflowPath;
}
async function getStartNodeCwd(threadId: string): Promise<string> {
const uwf = await createUwfStore(storageRoot);
const index = await loadThreadsIndex(storageRoot);
const headHash = index[threadId as ThreadId];
expect(headHash).toBeDefined();
const startNode = uwf.store.get(headHash as CasRef);
expect(startNode).not.toBe(null);
expect(startNode?.type).toBe(uwf.schemas.startNode);
const startPayload = startNode?.payload as StartNodePayload;
return startPayload.cwd;
}
test("thread start with custom cwd via cmdThreadStart", async () => {
await setupTestEnv();
const workflowPath = await createTestWorkflow();
const testCwd = "/test/custom/path";
const result = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir, testCwd);
expect(result.thread).toBeDefined();
const actualCwd = await getStartNodeCwd(result.thread);
expect(actualCwd).toBe(testCwd);
await teardown();
});
test("thread start without cwd defaults to process.cwd()", async () => {
await setupTestEnv();
const workflowPath = await createTestWorkflow();
// Call without cwd parameter (it defaults to process.cwd())
const result = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
expect(result.thread).toBeDefined();
const actualCwd = await getStartNodeCwd(result.thread);
expect(actualCwd).toBe(process.cwd());
await teardown();
});
test("thread start with relative path fails", async () => {
await setupTestEnv();
const workflowPath = await createTestWorkflow();
await expect(
cmdThreadStart(storageRoot, workflowPath, "test", tmpDir, "relative/path"),
).rejects.toThrow();
await teardown();
});
test("CLI accepts --cwd option without error", async () => {
await setupTestEnv();
const workflowPath = await createTestWorkflow();
const testCwd = "/test/cli/path";
const uwfBin = join(process.cwd(), "dist", "cli.js");
// Register the workflow
execFileSync("node", [uwfBin, "workflow", "add", workflowPath], {
env: { ...process.env, UWF_STORAGE_ROOT: storageRoot },
encoding: "utf8",
});
// Verify CLI accepts --cwd option (no error thrown)
const output = execFileSync(
"node",
[uwfBin, "thread", "start", "test-cwd-cli", "-p", "test prompt", "--cwd", testCwd],
{
env: { ...process.env, UWF_STORAGE_ROOT: storageRoot },
encoding: "utf8",
},
);
const result = JSON.parse(output);
expect(result.thread).toBeDefined();
expect(result.workflow).toBeDefined();
// The fact that we got here without throwing means CLI accepted the --cwd option
// The actual cwd functionality is tested by the other tests using cmdThreadStart directly
await teardown();
});
});
@@ -51,11 +51,11 @@ function makeWorkflow(overrides?: Partial<WorkflowPayload>): WorkflowPayload {
}, },
}, },
graph: { graph: {
$START: { _: { role: "writer", prompt: "Begin writing" } }, $START: { _: { role: "writer", prompt: "Begin writing", location: null } },
writer: { _: { role: "reviewer", prompt: "Review this: {{{plan}}}" } }, writer: { _: { role: "reviewer", prompt: "Review this: {{{plan}}}", location: null } },
reviewer: { reviewer: {
approved: { role: "$END", prompt: "Done: {{{summary}}}" }, approved: { role: "$END", prompt: "Done: {{{summary}}}", location: null },
rejected: { role: "writer", prompt: "Fix: {{{reason}}}" }, rejected: { role: "writer", prompt: "Fix: {{{reason}}}", location: null },
}, },
}, },
}; };
@@ -67,7 +67,7 @@ function makeWorkflow(overrides?: Partial<WorkflowPayload>): WorkflowPayload {
describe("Suite 1: Role Reference Integrity", () => { describe("Suite 1: Role Reference Integrity", () => {
test("1.1 graph references unknown role", () => { test("1.1 graph references unknown role", () => {
const wf = makeWorkflow(); const wf = makeWorkflow();
wf.graph.nonexistent = { _: { role: "$END", prompt: "done" } }; wf.graph.nonexistent = { _: { role: "$END", prompt: "done", location: null } };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes('unknown role "nonexistent"'))).toBe(true); expect(errors.some((e) => e.includes('unknown role "nonexistent"'))).toBe(true);
}); });
@@ -138,8 +138,8 @@ describe("Suite 2: Graph Structure", () => {
test("2.2 $START has multiple status keys", () => { test("2.2 $START has multiple status keys", () => {
const wf = makeWorkflow(); const wf = makeWorkflow();
wf.graph.$START = { wf.graph.$START = {
_: { role: "writer", prompt: "Begin" }, _: { role: "writer", prompt: "Begin", location: null },
other: { role: "reviewer", prompt: "Also" }, other: { role: "reviewer", prompt: "Also", location: null },
}; };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect( expect(
@@ -149,7 +149,7 @@ describe("Suite 2: Graph Structure", () => {
test("2.3 $START edge uses non-_ status", () => { test("2.3 $START edge uses non-_ status", () => {
const wf = makeWorkflow(); const wf = makeWorkflow();
wf.graph.$START = { ready: { role: "writer", prompt: "Begin" } }; wf.graph.$START = { ready: { role: "writer", prompt: "Begin", location: null } };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect( expect(
errors.some((e) => e.includes('$START must have exactly one edge with status "_"')), errors.some((e) => e.includes('$START must have exactly one edge with status "_"')),
@@ -158,7 +158,7 @@ describe("Suite 2: Graph Structure", () => {
test("2.4 $END has outgoing edges", () => { test("2.4 $END has outgoing edges", () => {
const wf = makeWorkflow(); const wf = makeWorkflow();
wf.graph.$END = { _: { role: "writer", prompt: "Loop" } }; wf.graph.$END = { _: { role: "writer", prompt: "Loop", location: null } };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("$END must not have outgoing edges"))).toBe(true); expect(errors.some((e) => e.includes("$END must not have outgoing edges"))).toBe(true);
}); });
@@ -177,7 +177,7 @@ describe("Suite 2: Graph Structure", () => {
required: ["$status"], required: ["$status"],
} as unknown as string, } as unknown as string,
}; };
wf.graph.isolated = { _: { role: "$END", prompt: "done" } }; wf.graph.isolated = { _: { role: "$END", prompt: "done", location: null } };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes('role "isolated" is not reachable from $START'))).toBe( expect(errors.some((e) => e.includes('role "isolated" is not reachable from $START'))).toBe(
true, true,
@@ -186,7 +186,7 @@ describe("Suite 2: Graph Structure", () => {
test("2.6 edge target references invalid role", () => { test("2.6 edge target references invalid role", () => {
const wf = makeWorkflow(); const wf = makeWorkflow();
wf.graph.writer = { _: { role: "ghost", prompt: "Go to ghost" } }; wf.graph.writer = { _: { role: "ghost", prompt: "Go to ghost", location: null } };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes('unknown target role "ghost"'))).toBe(true); expect(errors.some((e) => e.includes('unknown target role "ghost"'))).toBe(true);
}); });
@@ -196,8 +196,8 @@ describe("Suite 3: Status-Edge Consistency", () => {
test("3.1 single-exit role with multiple graph keys", () => { test("3.1 single-exit role with multiple graph keys", () => {
const wf = makeWorkflow(); const wf = makeWorkflow();
wf.graph.writer = { wf.graph.writer = {
_: { role: "reviewer", prompt: "Review" }, _: { role: "reviewer", prompt: "Review", location: null },
extra: { role: "$END", prompt: "Done" }, extra: { role: "$END", prompt: "Done", location: null },
}; };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect( expect(
@@ -209,7 +209,7 @@ describe("Suite 3: Status-Edge Consistency", () => {
test("3.2 single-exit role missing _ key", () => { test("3.2 single-exit role missing _ key", () => {
const wf = makeWorkflow(); const wf = makeWorkflow();
wf.graph.writer = { done: { role: "reviewer", prompt: "Review" } }; wf.graph.writer = { done: { role: "reviewer", prompt: "Review", location: null } };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect( expect(
errors.some((e) => e.includes('role "writer" is single-exit but graph has no "_" key')), errors.some((e) => e.includes('role "writer" is single-exit but graph has no "_" key')),
@@ -219,9 +219,9 @@ describe("Suite 3: Status-Edge Consistency", () => {
test("3.3 multi-exit role with extra statuses", () => { test("3.3 multi-exit role with extra statuses", () => {
const wf = makeWorkflow(); const wf = makeWorkflow();
wf.graph.reviewer = { wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done" }, approved: { role: "$END", prompt: "Done", location: null },
rejected: { role: "writer", prompt: "Fix" }, rejected: { role: "writer", prompt: "Fix", location: null },
timeout: { role: "$END", prompt: "Timed out" }, timeout: { role: "$END", prompt: "Timed out", location: null },
}; };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect( expect(
@@ -232,7 +232,7 @@ describe("Suite 3: Status-Edge Consistency", () => {
test("3.4 multi-exit role missing a status", () => { test("3.4 multi-exit role missing a status", () => {
const wf = makeWorkflow(); const wf = makeWorkflow();
wf.graph.reviewer = { wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done" }, approved: { role: "$END", prompt: "Done", location: null },
}; };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect( expect(
@@ -242,7 +242,7 @@ describe("Suite 3: Status-Edge Consistency", () => {
test("3.5 multi-exit role with _ key", () => { test("3.5 multi-exit role with _ key", () => {
const wf = makeWorkflow(); const wf = makeWorkflow();
wf.graph.reviewer = { _: { role: "$END", prompt: "Done" } }; wf.graph.reviewer = { _: { role: "$END", prompt: "Done", location: null } };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes('role "reviewer" is multi-exit but graph uses "_"'))).toBe( expect(errors.some((e) => e.includes('role "reviewer" is multi-exit but graph uses "_"'))).toBe(
true, true,
@@ -265,8 +265,8 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
} as unknown as string, } as unknown as string,
}; };
wf.graph.reviewer = { wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done" }, approved: { role: "$END", prompt: "Done", location: null },
rejected: { role: "writer", prompt: "Fix: {{{comments}}}" }, rejected: { role: "writer", prompt: "Fix: {{{comments}}}", location: null },
}; };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect(errors).toEqual([]); expect(errors).toEqual([]);
@@ -286,9 +286,9 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
} as unknown as string, } as unknown as string,
}; };
wf.graph.reviewer = { wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done" }, approved: { role: "$END", prompt: "Done", location: null },
rejected: { role: "writer", prompt: "Fix" }, rejected: { role: "writer", prompt: "Fix", location: null },
timeout: { role: "$END", prompt: "Timed out" }, timeout: { role: "$END", prompt: "Timed out", location: null },
}; };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("extra status keys: timeout"))).toBe(true); expect(errors.some((e) => e.includes("extra status keys: timeout"))).toBe(true);
@@ -308,7 +308,7 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
} as unknown as string, } as unknown as string,
}; };
wf.graph.reviewer = { wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done" }, approved: { role: "$END", prompt: "Done", location: null },
}; };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("missing status keys: rejected"))).toBe(true); expect(errors.some((e) => e.includes("missing status keys: rejected"))).toBe(true);
@@ -327,7 +327,7 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
required: ["$status", "plan"], required: ["$status", "plan"],
} as unknown as string, } as unknown as string,
}; };
wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{plan}}}" } }; wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{plan}}}", location: null } };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect(errors).toEqual([]); expect(errors).toEqual([]);
}); });
@@ -346,8 +346,8 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
} as unknown as string, } as unknown as string,
}; };
wf.graph.reviewer = { wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done: {{{nonexistent}}}" }, approved: { role: "$END", prompt: "Done: {{{nonexistent}}}", location: null },
rejected: { role: "writer", prompt: "Fix: {{{comments}}}" }, rejected: { role: "writer", prompt: "Fix: {{{comments}}}", location: null },
}; };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("nonexistent") && e.includes("not found"))).toBe(true); expect(errors.some((e) => e.includes("nonexistent") && e.includes("not found"))).toBe(true);
@@ -357,7 +357,7 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
describe("Suite 4: Mustache Template Variable Existence", () => { describe("Suite 4: Mustache Template Variable Existence", () => {
test("4.1 prompt references nonexistent variable (single-exit)", () => { test("4.1 prompt references nonexistent variable (single-exit)", () => {
const wf = makeWorkflow(); const wf = makeWorkflow();
wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{branch}}}" } }; wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{branch}}}", location: null } };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect( expect(
errors.some((e) => errors.some((e) =>
@@ -369,8 +369,8 @@ describe("Suite 4: Mustache Template Variable Existence", () => {
test("4.2 prompt references nonexistent variable (multi-exit)", () => { test("4.2 prompt references nonexistent variable (multi-exit)", () => {
const wf = makeWorkflow(); const wf = makeWorkflow();
wf.graph.reviewer = { wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done: {{{branch}}}" }, approved: { role: "$END", prompt: "Done: {{{branch}}}", location: null },
rejected: { role: "writer", prompt: "Fix: {{{reason}}}" }, rejected: { role: "writer", prompt: "Fix: {{{reason}}}", location: null },
}; };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect( expect(
@@ -388,7 +388,7 @@ describe("Suite 4: Mustache Template Variable Existence", () => {
test("4.4 $status variable is always valid", () => { test("4.4 $status variable is always valid", () => {
const wf = makeWorkflow(); const wf = makeWorkflow();
wf.graph.writer = { _: { role: "reviewer", prompt: "Status: {{$status}}" } }; wf.graph.writer = { _: { role: "reviewer", prompt: "Status: {{$status}}", location: null } };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect(errors).toEqual([]); expect(errors).toEqual([]);
}); });
@@ -461,9 +461,9 @@ describe("Suite 6: Multiple Errors Collection", () => {
} as unknown as string, } as unknown as string,
}; };
// unknown graph reference // unknown graph reference
wf.graph.nonexistent = { _: { role: "$END", prompt: "done" } }; wf.graph.nonexistent = { _: { role: "$END", prompt: "done", location: null } };
// bad mustache var // bad mustache var
wf.graph.writer = { _: { role: "reviewer", prompt: "{{{badvar}}}" } }; wf.graph.writer = { _: { role: "reviewer", prompt: "{{{badvar}}}", location: null } };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect(errors.length).toBeGreaterThanOrEqual(3); expect(errors.length).toBeGreaterThanOrEqual(3);
}); });
@@ -41,8 +41,8 @@ function makeMinimalPayload(name: string, description: string): WorkflowPayload
}, },
}, },
graph: { graph: {
$START: { _: { role: "worker", prompt: "start working" } }, $START: { _: { role: "worker", prompt: "start working", location: null } },
worker: { _: { role: "$END", prompt: "done" } }, worker: { _: { role: "$END", prompt: "done", location: null } },
}, },
}; };
} }
+11 -5
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol"; import type { CasRef, ThreadId, ThreadStatus } from "@uncaged/workflow-protocol";
import { Command } from "commander"; import { Command } from "commander";
import { import {
cmdCasGet, cmdCasGet,
@@ -38,7 +38,6 @@ import {
cmdThreadStart, cmdThreadStart,
cmdThreadStop, cmdThreadStop,
THREAD_READ_DEFAULT_QUOTA, THREAD_READ_DEFAULT_QUOTA,
type ThreadStatus,
} from "./commands/thread.js"; } from "./commands/thread.js";
import { parseTimeInput } from "./commands/thread-time-parser.js"; import { parseTimeInput } from "./commands/thread-time-parser.js";
import { cmdWorkflowAdd, cmdWorkflowList, cmdWorkflowShow } from "./commands/workflow.js"; import { cmdWorkflowAdd, cmdWorkflowList, cmdWorkflowShow } from "./commands/workflow.js";
@@ -118,10 +117,17 @@ thread
.description("Create a thread without executing") .description("Create a thread without executing")
.argument("<workflow>", "Workflow name or hash") .argument("<workflow>", "Workflow name or hash")
.requiredOption("-p, --prompt <text>", "User prompt") .requiredOption("-p, --prompt <text>", "User prompt")
.action((workflow: string, opts: { prompt: string }) => { .option("--cwd <path>", "Working directory for thread execution (default: process.cwd())")
.action((workflow: string, opts: { prompt: string; cwd: string | undefined }) => {
const storageRoot = resolveStorageRoot(); const storageRoot = resolveStorageRoot();
runAction(async () => { runAction(async () => {
const result = await cmdThreadStart(storageRoot, workflow, opts.prompt, process.cwd()); const result = await cmdThreadStart(
storageRoot,
workflow,
opts.prompt,
process.cwd(),
opts.cwd ?? process.cwd(),
);
writeOutput(result); writeOutput(result);
}); });
}); });
@@ -564,7 +570,7 @@ program
.option("--base-url <url>", "OpenAI-compatible API base URL") .option("--base-url <url>", "OpenAI-compatible API base URL")
.option("--api-key <key>", "API key") .option("--api-key <key>", "API key")
.option("--model <name>", "Default model name") .option("--model <name>", "Default model name")
.option("--agent <name>", "Default agent alias") .option("--agent <name>", "Default agent adapter (e.g. hermes → uwf-hermes)")
.action( .action(
(opts: { (opts: {
provider?: string; provider?: string;
+18 -3
View File
@@ -5,7 +5,10 @@ import { parse, stringify } from "yaml";
/** /**
* Valid configuration key schema * Valid configuration key schema
*/ */
const VALID_CONFIG_KEYS: Record<string, { nested: boolean; knownFields?: string[] }> = { const VALID_CONFIG_KEYS: Record<
string,
{ nested: boolean; knownFields?: string[]; minDepth?: number }
> = {
providers: { providers: {
nested: true, nested: true,
knownFields: ["baseUrl", "apiKey"], knownFields: ["baseUrl", "apiKey"],
@@ -18,6 +21,17 @@ const VALID_CONFIG_KEYS: Record<string, { nested: boolean; knownFields?: string[
nested: true, nested: true,
knownFields: ["command", "args"], knownFields: ["command", "args"],
}, },
agentOverrides: {
nested: true,
// agentOverrides.<workflowName>.<roleName> = agentAlias (string value)
// No knownFields — workflow/role names are user-defined
},
modelOverrides: {
nested: true,
minDepth: 2,
// modelOverrides.<scenario> = modelAlias (string value)
// No knownFields — scenarios are user-defined
},
defaultAgent: { nested: false }, defaultAgent: { nested: false },
defaultModel: { nested: false }, defaultModel: { nested: false },
}; };
@@ -43,8 +57,9 @@ function validateConfigKey(path: string[]): void {
throw new Error(`${topLevel} is a scalar key and cannot have nested properties`); throw new Error(`${topLevel} is a scalar key and cannot have nested properties`);
} }
// Nested keys must have at least 3 segments (e.g., providers.myProvider.baseUrl) // Nested keys must have at least minDepth segments (default 3)
if (schema.nested && path.length < 3) { const minDepth = schema.minDepth ?? 3;
if (schema.nested && path.length < minDepth) {
const fields = schema.knownFields?.join(", ") ?? ""; const fields = schema.knownFields?.join(", ") ?? "";
throw new Error( throw new Error(
`Incomplete path for ${topLevel}. Must specify a field (e.g., ${topLevel}.<name>.<field>). Valid fields: ${fields}`, `Incomplete path for ${topLevel}. Must specify a field (e.g., ${topLevel}.<name>.<field>). Valid fields: ${fields}`,
+82 -8
View File
@@ -12,6 +12,7 @@ import type {
StepOutput, StepOutput,
ThreadId, ThreadId,
ThreadListItem, ThreadListItem,
ThreadStatus,
ThreadsIndex, ThreadsIndex,
WorkflowConfig, WorkflowConfig,
WorkflowPayload, WorkflowPayload,
@@ -22,6 +23,7 @@ import {
generateUlid, generateUlid,
type ProcessLogger, type ProcessLogger,
} from "@uncaged/workflow-util"; } from "@uncaged/workflow-util";
import type { AdapterOutput } from "@uncaged/workflow-util-agent";
import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-util-agent"; import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-util-agent";
import { config as loadDotenv } from "dotenv"; import { config as loadDotenv } from "dotenv";
import { parse } from "yaml"; import { parse } from "yaml";
@@ -55,6 +57,21 @@ const END_ROLE = "$END";
const START_ROLE = "$START"; const START_ROLE = "$START";
export const THREAD_READ_DEFAULT_QUOTA = 4000; export const THREAD_READ_DEFAULT_QUOTA = 4000;
/**
* Derive the current/next role from the workflow graph and chain state.
* Returns null when the next role is $END or evaluation fails.
*/
function resolveCurrentRole(uwf: UwfStore, head: CasRef, workflowRef: CasRef): string | null {
const chain = walkChain(uwf, head);
const { lastRole, lastOutput } = resolveEvaluateArgs(uwf, chain);
const workflow = loadWorkflowPayload(uwf, workflowRef);
const result = evaluate(workflow.graph, lastRole, lastOutput);
if (!result.ok) {
return null;
}
return result.value.role === END_ROLE ? null : result.value.role;
}
const PL_THREAD_START = "7HNQ4B2X"; const PL_THREAD_START = "7HNQ4B2X";
const PL_MODERATOR = "M3K8V9T1"; const PL_MODERATOR = "M3K8V9T1";
const PL_AGENT_SPAWN = "R5J2W8N4"; const PL_AGENT_SPAWN = "R5J2W8N4";
@@ -266,7 +283,13 @@ export async function cmdThreadStart(
workflowId: string, workflowId: string,
prompt: string, prompt: string,
projectRoot: string, projectRoot: string,
cwd: string = process.cwd(),
): Promise<StartOutput> { ): Promise<StartOutput> {
// Validate cwd is an absolute path
if (!isAbsolute(cwd)) {
fail("cwd must be an absolute path");
}
const uwf = await createUwfStore(storageRoot); const uwf = await createUwfStore(storageRoot);
const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId, projectRoot); const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId, projectRoot);
@@ -278,6 +301,7 @@ export async function cmdThreadStart(
const startPayload: StartNodePayload = { const startPayload: StartNodePayload = {
workflow: workflowHash, workflow: workflowHash,
prompt, prompt,
cwd,
}; };
const headHash = await uwf.store.put(uwf.schemas.startNode, startPayload); const headHash = await uwf.store.put(uwf.schemas.startNode, startPayload);
@@ -308,10 +332,18 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
if (workflow === null) { if (workflow === null) {
fail(`failed to resolve workflow from head: ${activeHead}`); 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";
const currentRole = resolveCurrentRole(uwf, activeHead, workflow);
return { return {
workflow, workflow,
thread: threadId, thread: threadId,
head: activeHead, head: activeHead,
status,
currentRole,
done: false, done: false,
background: null, background: null,
}; };
@@ -319,10 +351,14 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
const hist = await findThreadInHistory(storageRoot, threadId); const hist = await findThreadInHistory(storageRoot, threadId);
if (hist !== null) { if (hist !== null) {
const status: ThreadStatus = hist.reason === "cancelled" ? "cancelled" : "completed";
return { return {
workflow: hist.workflow, workflow: hist.workflow,
thread: threadId, thread: threadId,
head: hist.head, head: hist.head,
status,
currentRole: null,
done: true, done: true,
background: null, background: null,
}; };
@@ -331,10 +367,9 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
fail(`thread not found: ${threadId}`); fail(`thread not found: ${threadId}`);
} }
export type ThreadStatus = "idle" | "running" | "completed" | "cancelled";
export type ThreadListItemWithStatus = ThreadListItem & { export type ThreadListItemWithStatus = ThreadListItem & {
status: ThreadStatus; status: ThreadStatus;
currentRole: string | null;
}; };
async function threadListItemFromActive( async function threadListItemFromActive(
@@ -352,7 +387,13 @@ async function threadListItemFromActive(
const runningMarker = await isThreadRunning(storageRoot, threadId); const runningMarker = await isThreadRunning(storageRoot, threadId);
const status: ThreadStatus = runningMarker !== null ? "running" : "idle"; const status: ThreadStatus = runningMarker !== null ? "running" : "idle";
return { thread: threadId, workflow, head, status }; return {
thread: threadId,
workflow,
head,
status,
currentRole: resolveCurrentRole(uwf, head, workflow),
};
} }
async function collectActiveThreads( async function collectActiveThreads(
@@ -390,6 +431,7 @@ async function collectCompletedThreads(
workflow: entry.workflow, workflow: entry.workflow,
head: entry.head, head: entry.head,
status: entry.reason === "cancelled" ? "cancelled" : "completed", status: entry.reason === "cancelled" ? "cancelled" : "completed",
currentRole: null,
}); });
} }
} }
@@ -772,7 +814,8 @@ function spawnAgent(
threadId: ThreadId, threadId: ThreadId,
role: string, role: string,
edgePrompt: string, edgePrompt: string,
): CasRef { cwd: string,
): AdapterOutput {
const argv = [...agent.args, "--thread", threadId, "--role", role, "--prompt", edgePrompt]; const argv = [...agent.args, "--thread", threadId, "--role", role, "--prompt", edgePrompt];
let stdout: string; let stdout: string;
try { try {
@@ -780,6 +823,7 @@ function spawnAgent(
encoding: "utf8", encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 50 * 1024 * 1024, // 50 MB — stream-json output can be large maxBuffer: 50 * 1024 * 1024, // 50 MB — stream-json output can be large
cwd,
}); });
} catch (e) { } catch (e) {
const err = e as NodeJS.ErrnoException & { stderr?: Buffer | string | null }; const err = e as NodeJS.ErrnoException & { stderr?: Buffer | string | null };
@@ -794,10 +838,22 @@ function spawnAgent(
} }
const line = stdout.trim().split("\n").pop()?.trim() ?? ""; const line = stdout.trim().split("\n").pop()?.trim() ?? "";
if (!isCasRef(line)) { let parsed: unknown;
failStep(plog, `agent stdout is not a valid CAS hash: ${line || "(empty)"}`); try {
parsed = JSON.parse(line);
} catch {
failStep(plog, `agent stdout last line is not valid JSON: ${line || "(empty)"}`);
} }
return line; const obj = parsed as Record<string, unknown>;
if (
typeof obj !== "object" ||
obj === null ||
typeof obj.stepHash !== "string" ||
!isCasRef(obj.stepHash as string)
) {
failStep(plog, `agent stdout JSON missing valid stepHash: ${line}`);
}
return obj as unknown as AdapterOutput;
} }
async function archiveThread( async function archiveThread(
@@ -908,6 +964,8 @@ async function cmdThreadStepBackground(
failStep(plog, `thread not active: ${threadId}`); failStep(plog, `thread not active: ${threadId}`);
} }
const uwf = await createUwfStore(storageRoot);
// Spawn detached background process // Spawn detached background process
const scriptPath = process.argv[1]; const scriptPath = process.argv[1];
if (scriptPath === undefined) { if (scriptPath === undefined) {
@@ -938,6 +996,8 @@ async function cmdThreadStepBackground(
workflow: workflowHash, workflow: workflowHash,
thread: threadId, thread: threadId,
head: headHash, head: headHash,
status: "running",
currentRole: resolveCurrentRole(uwf, headHash, workflowHash),
done: false, done: false,
background: true, background: true,
}, },
@@ -980,6 +1040,8 @@ async function cmdThreadStepOnce(
workflow: workflowHash, workflow: workflowHash,
thread: threadId, thread: threadId,
head: headHash, head: headHash,
status: "completed",
currentRole: null,
done: true, done: true,
background: null, background: null,
}; };
@@ -987,6 +1049,11 @@ async function cmdThreadStepOnce(
const role = nextResult.value.role; const role = nextResult.value.role;
const edgePrompt = nextResult.value.prompt; const edgePrompt = nextResult.value.prompt;
// Resolve cwd: use edge location if provided, otherwise inherit thread.cwd
const threadCwd = chain.start.cwd;
const effectiveCwd = nextResult.value.location !== null ? nextResult.value.location : threadCwd;
const config = await loadWorkflowConfig(storageRoot); const config = await loadWorkflowConfig(storageRoot);
const agent = resolveAgentConfig(config, workflow, role, agentOverride); const agent = resolveAgentConfig(config, workflow, role, agentOverride);
@@ -995,7 +1062,8 @@ async function cmdThreadStepOnce(
}); });
loadDotenv({ path: getEnvPath(storageRoot) }); loadDotenv({ path: getEnvPath(storageRoot) });
const newHead = spawnAgent(plog, agent, threadId, role, edgePrompt); const agentResult = spawnAgent(plog, agent, threadId, role, edgePrompt, effectiveCwd);
const newHead = agentResult.stepHash as CasRef;
plog.log(PL_AGENT_DONE, `agent returned head=${newHead}`, null); plog.log(PL_AGENT_DONE, `agent returned head=${newHead}`, null);
@@ -1027,10 +1095,16 @@ async function cmdThreadStepOnce(
await archiveThread(storageRoot, threadId, workflowHash, newHead); await archiveThread(storageRoot, threadId, workflowHash, newHead);
} }
// Determine status based on whether thread is done and running state
const status: ThreadStatus = done ? "completed" : "idle";
const currentRole = done ? null : afterResult.value.role;
return { return {
workflow: workflowHash, workflow: workflowHash,
thread: threadId, thread: threadId,
head: newHead, head: newHead,
status,
currentRole,
done, done,
background: null, background: null,
}; };
@@ -61,6 +61,7 @@ function normalizeGraph(
normalized[status] = { normalized[status] = {
role: target.role, role: target.role,
prompt: target.prompt, prompt: target.prompt,
location: target.location ?? null,
}; };
} }
result[node] = normalized; result[node] = normalized;
@@ -0,0 +1,198 @@
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 = {
planner: {
ready: {
role: "coder",
prompt: "Implement the code",
location: null,
},
},
};
const result = evaluate(graph, "planner", { $status: "ready" });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.location).toBe(null);
}
});
test("resolves static location string", () => {
const graph = {
planner: {
ready: {
role: "coder",
prompt: "Implement the code",
location: "/static/path",
},
},
};
const result = evaluate(graph, "planner", { $status: "ready" });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.location).toBe("/static/path");
}
});
test("resolves mustache template location", () => {
const graph = {
planner: {
ready: {
role: "coder",
prompt: "Implement the code",
location: "{{{repoPath}}}",
},
},
};
const result = evaluate(graph, "planner", {
$status: "ready",
repoPath: "/home/user/repo",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.location).toBe("/home/user/repo");
}
});
test("resolves mustache template with multiple variables", () => {
const graph = {
planner: {
ready: {
role: "coder",
prompt: "Implement the code",
location: "{{{basePath}}}/{{{projectName}}}",
},
},
};
const result = evaluate(graph, "planner", {
$status: "ready",
basePath: "/home/user",
projectName: "myproject",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.location).toBe("/home/user/myproject");
}
});
test("handles missing template variable gracefully", () => {
const graph = {
planner: {
ready: {
role: "coder",
prompt: "Implement the code",
location: "{{{repoPath}}}",
},
},
};
const result = evaluate(graph, "planner", { $status: "ready" });
expect(result.ok).toBe(true);
if (result.ok) {
// Mustache renders missing variables as empty string
expect(result.value.location).toBe("");
}
});
});
@@ -43,7 +43,16 @@ export function evaluate(
try { try {
const prompt = mustache.render(target.prompt, lastOutput); const prompt = mustache.render(target.prompt, lastOutput);
return { ok: true, value: { role: target.role, prompt } }; 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) { } catch (error) {
return { return {
ok: false, ok: false,
@@ -4,4 +4,6 @@ export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
export type EvaluateResult = { export type EvaluateResult = {
role: string; role: string;
prompt: string; prompt: string;
/** Resolved working directory from edge location field (null = inherit thread cwd). */
location: string | null;
}; };
+24 -2
View File
@@ -36,8 +36,13 @@ function isTarget(value: unknown): boolean {
if (!isRecord(value)) { if (!isRecord(value)) {
return false; return false;
} }
const hasValidLocation =
value.location === undefined || value.location === null || typeof value.location === "string";
return ( return (
typeof value.role === "string" && typeof value.prompt === "string" && value.prompt.trim() !== "" typeof value.role === "string" &&
typeof value.prompt === "string" &&
value.prompt.trim() !== "" &&
hasValidLocation
); );
} }
@@ -95,5 +100,22 @@ export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
if (!isStringRecord(raw.roles, isRoleDefinition) || !isGraph(raw.graph)) { if (!isStringRecord(raw.roles, isRoleDefinition) || !isGraph(raw.graph)) {
return null; return null;
} }
return raw as WorkflowPayload;
// Normalize location field: undefined → null
const normalized = { ...raw } as WorkflowPayload;
for (const roleName of Object.keys(normalized.graph)) {
const statusMap = normalized.graph[roleName];
if (statusMap !== undefined) {
for (const status of Object.keys(statusMap)) {
const target = statusMap[status];
if (target !== undefined) {
if (target.location === undefined) {
target.location = null;
}
}
}
}
}
return normalized;
} }
+4 -2
View File
@@ -1,10 +1,12 @@
# @uncaged/workflow-agent-hermes # @uncaged/workflow-agent-hermes
`uwf-hermes` agent — spawns Hermes chat via ACP and captures session detail. `uwf-hermes` — an **agent adapter** that bridges the `uwf` workflow engine and the Hermes CLI.
## Overview ## Overview
Layer 3 agent implementation. Wraps the Hermes CLI using the Agent Client Protocol (ACP). On first visit to a role it sends a composed prompt (role definition, task, history, edge prompt); on continuation it resumes the cached session. Session transcripts and raw output are stored as CAS detail nodes. `uwf-hermes` is an adapter (not the Hermes CLI itself). The `uwf` engine speaks a generic agent protocol (stdin/stdout frontmatter contract); `uwf-hermes` translates that protocol into Hermes ACP (Agent Client Protocol) calls. Other adapters (e.g. `uwf-claude-code`, `uwf-cursor`) do the same for their respective CLIs.
On first visit to a role it sends a composed prompt (role definition, task, history, edge prompt); on continuation it resumes the cached session. Session transcripts and raw output are stored as CAS detail nodes.
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-util-agent`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util` **Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-util-agent`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const PKG_ROOT = join(import.meta.dir, "..");
describe("Issue #551 — bin entry & engines", () => {
test("package.json declares bun in engines", () => {
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf-8"));
expect(pkg.engines).toBeDefined();
expect(pkg.engines.bun).toBeDefined();
expect(pkg.engines.bun).toMatch(/^>=?\s*[\d.]+/);
});
test("bin entry file has bun shebang", () => {
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf-8"));
const binPath = pkg.bin["uwf-hermes"];
const content = readFileSync(join(PKG_ROOT, binPath), "utf-8");
expect(content.startsWith("#!/usr/bin/env bun")).toBe(true);
});
test("README.md explains uwf-hermes is an adapter", () => {
const readme = readFileSync(join(PKG_ROOT, "README.md"), "utf-8");
expect(readme.toLowerCase()).toContain("adapter");
expect(readme).toMatch(/uwf-hermes/);
expect(readme).toMatch(/hermes/);
});
});
@@ -42,5 +42,8 @@
"bugs": { "bugs": {
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues" "url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
}, },
"engines": {
"bun": ">= 1.0.0"
},
"license": "MIT" "license": "MIT"
} }
@@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test";
import type { StartNodePayload, StepRecord, Target } from "../types.js";
describe("Protocol types for thread/edge location", () => {
describe("StartNodePayload", () => {
test("has required cwd field", () => {
const payload: StartNodePayload = {
workflow: "0123456789ABC",
prompt: "Test prompt",
cwd: "/home/user/project",
};
expect(payload.cwd).toBe("/home/user/project");
expect(typeof payload.cwd).toBe("string");
});
});
describe("StepRecord", () => {
test("has required cwd field", () => {
const record: StepRecord = {
role: "planner",
output: "0123456789ABC",
detail: "DEF0123456789",
agent: "uwf-hermes",
edgePrompt: "Plan the implementation",
startedAtMs: Date.now(),
completedAtMs: Date.now() + 1000,
cwd: "/home/user/project",
};
expect(record.cwd).toBe("/home/user/project");
expect(typeof record.cwd).toBe("string");
});
});
describe("Target", () => {
test("has location field that accepts string", () => {
const target: Target = {
role: "coder",
prompt: "Implement the code",
location: "/custom/path",
};
expect(target.location).toBe("/custom/path");
expect(typeof target.location).toBe("string");
});
test("has location field that accepts null", () => {
const target: Target = {
role: "coder",
prompt: "Implement the code",
location: null,
};
expect(target.location).toBe(null);
});
test("location supports mustache template syntax", () => {
const target: Target = {
role: "coder",
prompt: "Implement the code",
location: "{{{repoPath}}}",
};
expect(target.location).toBe("{{{repoPath}}}");
});
});
});
+1
View File
@@ -29,6 +29,7 @@ export type {
ThreadForkOutput, ThreadForkOutput,
ThreadId, ThreadId,
ThreadListItem, ThreadListItem,
ThreadStatus,
ThreadStepsOutput, ThreadStepsOutput,
ThreadsIndex, ThreadsIndex,
WorkflowConfig, WorkflowConfig,
+17 -2
View File
@@ -20,6 +20,9 @@ const TARGET: JSONSchema = {
properties: { properties: {
role: { type: "string" }, role: { type: "string" },
prompt: { type: "string" }, prompt: { type: "string" },
location: {
anyOf: [{ type: "string" }, { type: "null" }],
},
}, },
additionalProperties: false, additionalProperties: false,
}; };
@@ -49,10 +52,11 @@ export const WORKFLOW_SCHEMA: JSONSchema = {
export const START_NODE_SCHEMA: JSONSchema = { export const START_NODE_SCHEMA: JSONSchema = {
title: "StartNode", title: "StartNode",
type: "object", type: "object",
required: ["workflow", "prompt"], required: ["workflow", "prompt", "cwd"],
properties: { properties: {
workflow: { type: "string", format: "cas_ref" }, workflow: { type: "string", format: "cas_ref" },
prompt: { type: "string" }, prompt: { type: "string" },
cwd: { type: "string" },
}, },
additionalProperties: false, additionalProperties: false,
}; };
@@ -60,7 +64,17 @@ export const START_NODE_SCHEMA: JSONSchema = {
export const STEP_NODE_SCHEMA: JSONSchema = { export const STEP_NODE_SCHEMA: JSONSchema = {
title: "StepNode", title: "StepNode",
type: "object", type: "object",
required: ["start", "prev", "role", "output", "detail", "agent", "startedAtMs", "completedAtMs"], required: [
"start",
"prev",
"role",
"output",
"detail",
"agent",
"startedAtMs",
"completedAtMs",
"cwd",
],
properties: { properties: {
start: { type: "string", format: "cas_ref" }, start: { type: "string", format: "cas_ref" },
prev: { prev: {
@@ -73,6 +87,7 @@ export const STEP_NODE_SCHEMA: JSONSchema = {
edgePrompt: { type: "string" }, edgePrompt: { type: "string" },
startedAtMs: { type: "integer" }, startedAtMs: { type: "integer" },
completedAtMs: { type: "integer" }, completedAtMs: { type: "integer" },
cwd: { type: "string" },
}, },
additionalProperties: false, additionalProperties: false,
}; };
+19 -1
View File
@@ -18,6 +18,8 @@ export type StepRecord = {
startedAtMs: number; startedAtMs: number;
/** Date.now() after agent returns */ /** Date.now() after agent returns */
completedAtMs: number; completedAtMs: number;
/** Working directory where the agent executed. Missing in legacy nodes → "". */
cwd: string;
}; };
// ── 4.2 Workflow 定义 ─────────────────────────────────────────────── // ── 4.2 Workflow 定义 ───────────────────────────────────────────────
@@ -34,6 +36,8 @@ export type RoleDefinition = {
export type Target = { export type Target = {
role: string; role: string;
prompt: string; prompt: string;
/** Optional working directory override via mustache template. */
location: string | null;
}; };
export type WorkflowPayload = { export type WorkflowPayload = {
@@ -48,6 +52,8 @@ export type WorkflowPayload = {
export type StartNodePayload = { export type StartNodePayload = {
workflow: CasRef; workflow: CasRef;
prompt: string; prompt: string;
/** Working directory where the thread was created. */
cwd: string;
}; };
export type StepNodePayload = StepRecord & { export type StepNodePayload = StepRecord & {
@@ -70,17 +76,29 @@ export type ModeratorContext = {
// ── 4.5 CLI 输出 ──────────────────────────────────────────────────── // ── 4.5 CLI 输出 ────────────────────────────────────────────────────
/** Thread status — unified status representation */
export type ThreadStatus = "idle" | "running" | "completed" | "cancelled";
/** uwf thread start */ /** uwf thread start */
export type StartOutput = { export type StartOutput = {
workflow: CasRef; workflow: CasRef;
thread: ThreadId; thread: ThreadId;
}; };
/** uwf thread step / uwf thread show */ /**
* 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.
*/
export type StepOutput = { export type StepOutput = {
workflow: CasRef; workflow: CasRef;
thread: ThreadId; thread: ThreadId;
head: CasRef; head: CasRef;
status: ThreadStatus;
/** The current or next role. Null when completed, cancelled, or next is $END. */
currentRole: string | null;
done: boolean; done: boolean;
background: boolean | null; background: boolean | null;
}; };
@@ -0,0 +1,72 @@
import { createMemoryStore, putSchema } from "@uncaged/json-cas";
import { describe, expect, test } from "vitest";
import { tryFrontmatterFastPath } from "../src/frontmatter.js";
// ── Helpers ───────────────────────────────────────────────────────────────────
const PLANNER_SCHEMA = {
type: "object",
properties: {
$status: { type: "string", enum: ["ready", "failed"] },
plan: { type: "string" },
},
required: ["$status"],
additionalProperties: false,
};
describe("adapter-stdout: A4 retry loop survives JSON output", () => {
test("A4. first extraction fails, second succeeds — final result has correct data", async () => {
const store = createMemoryStore();
const schemaHash = await putSchema(store, PLANNER_SCHEMA);
// Simulate the retry loop from createAgent (run.ts lines 163-173):
// First attempt: agent outputs garbage (no frontmatter)
const badOutput = "Here is my response without frontmatter.\nJust plain text.";
const firstAttempt = await tryFrontmatterFastPath(badOutput, schemaHash, store);
expect(firstAttempt).toBeNull();
// Second attempt (after correction message): agent outputs valid frontmatter
const goodOutput = `---\n$status: ready\nplan: corrected-hash\n---\nCorrected body with valid frontmatter.`;
const secondAttempt = await tryFrontmatterFastPath(goodOutput, schemaHash, store);
expect(secondAttempt).not.toBeNull();
expect(secondAttempt!.outputHash).toMatch(/^[0-9A-Z]{13}$/);
expect(secondAttempt!.frontmatter).toEqual({ $status: "ready", plan: "corrected-hash" });
expect(secondAttempt!.body).toBe("Corrected body with valid frontmatter.");
// Verify the final AdapterOutput shape would be correct
const adapterOutput = {
stepHash: "MOCK_STEP_HASH",
detailHash: "MOCK_DETAIL_HA",
role: "planner",
frontmatter: secondAttempt!.frontmatter,
body: secondAttempt!.body,
startedAtMs: 1000,
completedAtMs: 2000,
};
const json = JSON.stringify(adapterOutput);
const parsed = JSON.parse(json);
expect(parsed.frontmatter).toEqual({ $status: "ready", plan: "corrected-hash" });
expect(parsed.body).toBe("Corrected body with valid frontmatter.");
expect(parsed.completedAtMs).toBeGreaterThanOrEqual(parsed.startedAtMs);
});
test("A4. all retries fail — extraction returns null on every attempt", async () => {
const store = createMemoryStore();
const schemaHash = await putSchema(store, PLANNER_SCHEMA);
const MAX_RETRIES = 2;
const badOutput = "No frontmatter here";
// Simulate MAX_FRONTMATTER_RETRIES iterations all failing
let extracted = await tryFrontmatterFastPath(badOutput, schemaHash, store);
for (let retry = 0; retry < MAX_RETRIES && extracted === null; retry++) {
// Each retry also gets bad output
extracted = await tryFrontmatterFastPath(badOutput, schemaHash, store);
}
expect(extracted).toBeNull();
});
});
@@ -0,0 +1,105 @@
import { createMemoryStore, putSchema } from "@uncaged/json-cas";
import { describe, expect, test } from "vitest";
import { tryFrontmatterFastPath } from "../src/frontmatter.js";
// ── Helpers ───────────────────────────────────────────────────────────────────
const PLANNER_SCHEMA = {
type: "object",
properties: {
$status: { type: "string", enum: ["ready", "failed"] },
plan: { type: "string" },
},
required: ["$status"],
additionalProperties: false,
};
const FRONTMATTER_SCHEMA = {
type: "object",
properties: {
status: { anyOf: [{ type: "string" }, { type: "null" }] },
next: { anyOf: [{ type: "string" }, { type: "null" }] },
confidence: { anyOf: [{ type: "number" }, { type: "null" }] },
artifacts: { type: "array", items: { type: "string" } },
scope: { type: "string" },
},
required: ["status", "next", "confidence", "artifacts", "scope"],
additionalProperties: false,
};
describe("adapter-stdout: FrontmatterFastPathResult includes frontmatter", () => {
test("A2. frontmatter field contains the parsed YAML frontmatter object", async () => {
const store = createMemoryStore();
const schemaHash = await putSchema(store, PLANNER_SCHEMA);
const raw = `---\n$status: ready\nplan: abc123\n---\nSome body text`;
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).not.toBeNull();
expect(result!.frontmatter).toEqual({ $status: "ready", plan: "abc123" });
});
test("A3. body field contains the markdown body after frontmatter", async () => {
const store = createMemoryStore();
const schemaHash = await putSchema(store, PLANNER_SCHEMA);
const raw = `---\n$status: ready\nplan: hash123\n---\nHere is the body.\n\nWith multiple paragraphs.`;
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).not.toBeNull();
expect(result!.body).toBe("Here is the body.\n\nWith multiple paragraphs.");
});
test("A1. result contains outputHash as valid CasRef", async () => {
const store = createMemoryStore();
const schemaHash = await putSchema(store, FRONTMATTER_SCHEMA);
const raw = `---\nstatus: done\nnext: null\nconfidence: 0.9\nartifacts: []\nscope: test\n---\nBody`;
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).not.toBeNull();
expect(result!.outputHash).toMatch(/^[0-9A-Z]{13}$/);
expect(result!.frontmatter).toBeDefined();
expect(result!.body).toBe("Body");
});
});
describe("adapter-stdout: AdapterOutput JSON shape", () => {
test("A5. JSON.stringify produces valid parseable JSON with all fields", () => {
const output = {
stepHash: "0123456789ABC",
detailHash: "DEFGH12345678",
role: "planner",
frontmatter: { $status: "ready", plan: "somehash" },
body: "Plan body text",
startedAtMs: 1000,
completedAtMs: 2000,
};
const json = JSON.stringify(output);
const parsed = JSON.parse(json);
expect(parsed.stepHash).toBe("0123456789ABC");
expect(parsed.detailHash).toBe("DEFGH12345678");
expect(parsed.role).toBe("planner");
expect(parsed.frontmatter).toEqual({ $status: "ready", plan: "somehash" });
expect(parsed.body).toBe("Plan body text");
expect(parsed.startedAtMs).toBe(1000);
expect(parsed.completedAtMs).toBe(2000);
});
test("completedAtMs >= startedAtMs", () => {
const output = {
stepHash: "0123456789ABC",
detailHash: "DEFGH12345678",
role: "planner",
frontmatter: {},
body: "",
startedAtMs: 1000,
completedAtMs: 2000,
};
expect(output.completedAtMs).toBeGreaterThanOrEqual(output.startedAtMs);
});
});
@@ -5,17 +5,13 @@ import { tryFrontmatterFastPath } from "../src/frontmatter.js";
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
/** JSON Schema that exactly matches the AgentFrontmatter fields. */ /** JSON Schema that matches the new status-only AgentFrontmatter. */
const FRONTMATTER_SCHEMA = { const STATUS_ONLY_SCHEMA = {
type: "object", type: "object",
properties: { properties: {
status: { anyOf: [{ type: "string" }, { type: "null" }] }, status: { anyOf: [{ type: "string" }, { type: "null" }] },
next: { anyOf: [{ type: "string" }, { type: "null" }] },
confidence: { anyOf: [{ type: "number" }, { type: "null" }] },
artifacts: { type: "array", items: { type: "string" } },
scope: { type: "string" },
}, },
required: ["status", "next", "confidence", "artifacts", "scope"], required: ["status"],
additionalProperties: false, additionalProperties: false,
}; };
@@ -56,24 +52,41 @@ async function makeStoreWithSchema(schema: Record<string, unknown>) {
return { store, schemaHash }; return { store, schemaHash };
} }
// ── STANDARD_KEYS ────────────────────────────────────────────────────────────
describe("STANDARD_KEYS contains only status", () => {
test("STANDARD_KEYS is ['status']", async () => {
// We verify indirectly: defaultCandidate (no schema fields) returns only { status }
const { store, schemaHash } = await makeStoreWithSchema({
type: "object",
properties: {
status: { anyOf: [{ type: "string" }, { type: "null" }] },
},
});
const raw = "---\nstatus: done\n---\n\nBody.";
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).not.toBeNull();
const node = store.get(result!.outputHash);
expect(node).not.toBeNull();
const payload = node!.payload as Record<string, unknown>;
expect(payload.status).toBe("done");
// Legacy fields must NOT be present
expect(payload.next).toBeUndefined();
expect(payload.confidence).toBeUndefined();
expect(payload.artifacts).toBeUndefined();
expect(payload.scope).toBeUndefined();
});
});
// ── Happy path ───────────────────────────────────────────────────────────────── // ── Happy path ─────────────────────────────────────────────────────────────────
describe("tryFrontmatterFastPath — happy path", () => { describe("tryFrontmatterFastPath — happy path", () => {
test("parses valid frontmatter and returns outputHash + stripped body", async () => { test("parses valid frontmatter and returns outputHash + stripped body", async () => {
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA); const { store, schemaHash } = await makeStoreWithSchema(STATUS_ONLY_SCHEMA);
const raw = [ const raw = ["---", "status: done", "---", "", "## Summary", "Work is complete."].join("\n");
"---",
"status: done",
"next: reviewer",
"confidence: 0.9",
"artifacts: [src/foo.ts]",
"scope: role",
"---",
"",
"## Summary",
"Work is complete.",
].join("\n");
const result = await tryFrontmatterFastPath(raw, schemaHash, store); const result = await tryFrontmatterFastPath(raw, schemaHash, store);
@@ -85,11 +98,10 @@ describe("tryFrontmatterFastPath — happy path", () => {
expect((result?.outputHash ?? "").length).toBeGreaterThan(0); expect((result?.outputHash ?? "").length).toBeGreaterThan(0);
}); });
test("stored CAS node payload matches frontmatter fields", async () => { test("stored CAS node payload has only status", async () => {
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA); const { store, schemaHash } = await makeStoreWithSchema(STATUS_ONLY_SCHEMA);
const raw = const raw = "---\nstatus: done\n---\n\nBody.";
"---\nstatus: done\nnext: null\nconfidence: null\nartifacts: []\nscope: role\n---\n\nBody.";
const result = await tryFrontmatterFastPath(raw, schemaHash, store); const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
@@ -98,10 +110,29 @@ describe("tryFrontmatterFastPath — happy path", () => {
expect(node).not.toBeNull(); expect(node).not.toBeNull();
const payload = node!.payload as Record<string, unknown>; const payload = node!.payload as Record<string, unknown>;
expect(payload.status).toBe("done"); expect(payload.status).toBe("done");
expect(payload.next).toBeNull(); expect(Object.keys(payload)).toEqual(["status"]);
expect(payload.confidence).toBeNull(); });
expect(payload.artifacts).toEqual([]); });
expect(payload.scope).toBe("role");
// ── Legacy fields in input are ignored ──────────────────────────────────────
describe("tryFrontmatterFastPath — legacy fields ignored", () => {
test("legacy fields in input do not appear in CAS output", async () => {
const { store, schemaHash } = await makeStoreWithSchema(STATUS_ONLY_SCHEMA);
const raw =
"---\nstatus: done\nnext: reviewer\nconfidence: 0.9\nartifacts: [a.ts]\nscope: thread\n---\n\nBody.";
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).not.toBeNull();
const node = store.get(result!.outputHash);
const payload = node!.payload as Record<string, unknown>;
expect(payload.status).toBe("done");
expect(payload.next).toBeUndefined();
expect(payload.confidence).toBeUndefined();
expect(payload.artifacts).toBeUndefined();
expect(payload.scope).toBeUndefined();
}); });
}); });
@@ -109,7 +140,7 @@ describe("tryFrontmatterFastPath — happy path", () => {
describe("tryFrontmatterFastPath — fallback: no frontmatter", () => { describe("tryFrontmatterFastPath — fallback: no frontmatter", () => {
test("returns null for plain markdown without frontmatter block", async () => { test("returns null for plain markdown without frontmatter block", async () => {
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA); const { store, schemaHash } = await makeStoreWithSchema(STATUS_ONLY_SCHEMA);
const result = await tryFrontmatterFastPath( const result = await tryFrontmatterFastPath(
"This is plain markdown without any frontmatter.", "This is plain markdown without any frontmatter.",
@@ -121,35 +152,13 @@ describe("tryFrontmatterFastPath — fallback: no frontmatter", () => {
}); });
}); });
// ── Fallback: invalid frontmatter ─────────────────────────────────────────────
describe("tryFrontmatterFastPath — fallback: invalid frontmatter", () => {
test("returns null when confidence is out of range [0, 1]", async () => {
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA);
const raw = "---\nstatus: done\nconfidence: 1.5\nscope: role\n---\n\nBody.";
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).toBeNull();
});
test("returns null when next contains whitespace", async () => {
const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA);
const raw = "---\nstatus: done\nnext: some role\nscope: role\n---\n\nBody.";
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).toBeNull();
});
});
// ── Fallback: schema mismatch ───────────────────────────────────────────────── // ── Fallback: schema mismatch ─────────────────────────────────────────────────
describe("tryFrontmatterFastPath — fallback: schema mismatch", () => { describe("tryFrontmatterFastPath — fallback: schema mismatch", () => {
test("returns null when outputSchema requires fields not in frontmatter", async () => { test("returns null when outputSchema requires fields not in frontmatter", async () => {
const { store, schemaHash } = await makeStoreWithSchema(STRICT_SCHEMA); const { store, schemaHash } = await makeStoreWithSchema(STRICT_SCHEMA);
const raw = "---\nstatus: done\nscope: role\n---\n\nBody."; const raw = "---\nstatus: done\n---\n\nBody.";
const result = await tryFrontmatterFastPath(raw, schemaHash, store); const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).toBeNull(); expect(result).toBeNull();
@@ -194,7 +203,7 @@ describe("tryFrontmatterFastPath — role-specific fields", () => {
test("returns null when required role-specific field is missing", async () => { test("returns null when required role-specific field is missing", async () => {
const { store, schemaHash } = await makeStoreWithSchema(REVIEWER_SCHEMA); const { store, schemaHash } = await makeStoreWithSchema(REVIEWER_SCHEMA);
const raw = "---\nstatus: done\nscope: role\n---\n\nBody."; const raw = "---\nstatus: done\n---\n\nBody.";
const result = await tryFrontmatterFastPath(raw, schemaHash, store); const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).toBeNull(); expect(result).toBeNull();
@@ -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");
});
});
@@ -130,6 +130,7 @@ async function buildHistory(
edgePrompt: step.edgePrompt ?? "", edgePrompt: step.edgePrompt ?? "",
startedAtMs: step.startedAtMs, startedAtMs: step.startedAtMs,
completedAtMs: step.completedAtMs, completedAtMs: step.completedAtMs,
cwd: step.cwd ?? "",
content, content,
}); });
} }
@@ -13,13 +13,14 @@ import { extractSchemaFields } from "./build-output-format-instruction.js";
const log = createLogger({ sink: { kind: "stderr" } }); const log = createLogger({ sink: { kind: "stderr" } });
const STANDARD_KEYS = ["status", "next", "confidence", "artifacts", "scope"] as const; const STANDARD_KEYS = ["status"] as const;
type StandardKey = (typeof STANDARD_KEYS)[number]; type StandardKey = (typeof STANDARD_KEYS)[number];
export type FrontmatterFastPathResult = { export type FrontmatterFastPathResult = {
body: string; body: string;
outputHash: CasRef; outputHash: CasRef;
frontmatter: Record<string, unknown>;
}; };
function extractYamlBlock(raw: string): string | null { function extractYamlBlock(raw: string): string | null {
@@ -62,10 +63,6 @@ function parseRawFrontmatterFields(raw: string): Record<string, unknown> {
function defaultCandidate(frontmatter: AgentFrontmatter): Record<string, unknown> { function defaultCandidate(frontmatter: AgentFrontmatter): Record<string, unknown> {
return { return {
status: frontmatter.status, status: frontmatter.status,
next: frontmatter.next,
confidence: frontmatter.confidence,
artifacts: [...frontmatter.artifacts],
scope: frontmatter.scope,
}; };
} }
@@ -73,14 +70,6 @@ function pickStandardField(frontmatter: AgentFrontmatter, key: StandardKey): unk
switch (key) { switch (key) {
case "status": case "status":
return frontmatter.status; return frontmatter.status;
case "next":
return frontmatter.next;
case "confidence":
return frontmatter.confidence;
case "artifacts":
return [...frontmatter.artifacts];
case "scope":
return frontmatter.scope;
} }
} }
@@ -98,9 +87,6 @@ function pickFieldValue(
} }
const coerced = pickStandardField(frontmatter, field); const coerced = pickStandardField(frontmatter, field);
if (field === "artifacts" || field === "scope") {
return coerced;
}
if (coerced !== null) { if (coerced !== null) {
return coerced; return coerced;
} }
@@ -110,8 +96,8 @@ function pickFieldValue(
/** /**
* Build a CAS candidate object from schema property keys and parsed frontmatter. * Build a CAS candidate object from schema property keys and parsed frontmatter.
* *
* When the schema has no inspectable properties, falls back to the five standard * When the schema has no inspectable properties, falls back to the standard
* agent frontmatter fields for backward compatibility. * agent frontmatter field (status only).
*/ */
function buildCandidate( function buildCandidate(
frontmatter: AgentFrontmatter, frontmatter: AgentFrontmatter,
@@ -191,5 +177,5 @@ export async function tryFrontmatterFastPath(
return null; return null;
} }
return { body, outputHash }; return { body, outputHash, frontmatter: candidate };
} }
+2 -1
View File
@@ -11,10 +11,11 @@ export {
} from "./extract.js"; } from "./extract.js";
export type { FrontmatterFastPathResult } from "./frontmatter.js"; export type { FrontmatterFastPathResult } from "./frontmatter.js";
export { tryFrontmatterFastPath } 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 { getCachedSessionId, getCachePath, setCachedSessionId } from "./session-cache.js";
export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js"; export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
export type { export type {
AdapterOutput,
AgentContext, AgentContext,
AgentContinueFn, AgentContinueFn,
AgentOptions, AgentOptions,
+36 -11
View File
@@ -6,7 +6,7 @@ import { buildContextWithMeta } from "./context.js";
import { tryFrontmatterFastPath } from "./frontmatter.js"; import { tryFrontmatterFastPath } from "./frontmatter.js";
import type { AgentStore } from "./storage.js"; import type { AgentStore } from "./storage.js";
import { getEnvPath, resolveStorageRoot } from "./storage.js"; import { getEnvPath, resolveStorageRoot } from "./storage.js";
import type { AgentOptions } from "./types.js"; import type { AdapterOutput, AgentOptions } from "./types.js";
const MAX_FRONTMATTER_RETRIES = 2; const MAX_FRONTMATTER_RETRIES = 2;
@@ -32,13 +32,16 @@ function getNamedArg(argv: string[], name: string): string {
return argv[idx + 1]; 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 threadId = getNamedArg(argv, "--thread");
const role = getNamedArg(argv, "--role"); const role = getNamedArg(argv, "--role");
const prompt = getNamedArg(argv, "--prompt"); const prompt = getNamedArg(argv, "--prompt");
if (threadId === "") fail(USAGE); if (threadId === "") fail(USAGE);
if (role === "") 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 }; return { threadId: threadId as ThreadId, role, prompt };
} }
@@ -72,6 +75,7 @@ async function writeStepNode(options: {
edgePrompt: options.edgePrompt, edgePrompt: options.edgePrompt,
startedAtMs: options.startedAtMs, startedAtMs: options.startedAtMs,
completedAtMs: options.completedAtMs, completedAtMs: options.completedAtMs,
cwd: process.cwd(),
}; };
const hash = await options.store.put(options.schemas.stepNode, payload); const hash = await options.store.put(options.schemas.stepNode, payload);
const node = options.store.get(hash); const node = options.store.get(hash);
@@ -81,14 +85,24 @@ async function writeStepNode(options: {
return hash; return hash;
} }
type ExtractedOutput = {
outputHash: CasRef;
frontmatter: Record<string, unknown>;
body: string;
};
async function tryExtractOutput( async function tryExtractOutput(
rawOutput: string, rawOutput: string,
outputSchema: CasRef, outputSchema: CasRef,
ctx: Awaited<ReturnType<typeof buildContextWithMeta>>, ctx: Awaited<ReturnType<typeof buildContextWithMeta>>,
): Promise<CasRef | null> { ): Promise<ExtractedOutput | null> {
const fastPath = await tryFrontmatterFastPath(rawOutput, outputSchema, ctx.meta.store); const fastPath = await tryFrontmatterFastPath(rawOutput, outputSchema, ctx.meta.store);
if (fastPath !== null) { if (fastPath !== null) {
return fastPath.outputHash; return {
outputHash: fastPath.outputHash,
frontmatter: fastPath.frontmatter,
body: fastPath.body,
};
} }
return null; return null;
} }
@@ -137,6 +151,7 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
const startedAtMs = Date.now(); const startedAtMs = Date.now();
let agentResult = await runWithMessage("agent run failed", () => options.run(ctx)); let agentResult = await runWithMessage("agent run failed", () => options.run(ctx));
agentResult.output = agentResult.output.trimStart();
// Preserve the primary detail from the first run — it contains the full // Preserve the primary detail from the first run — it contains the full
// tool-call turn history. Continuation retries only fix frontmatter // tool-call turn history. Continuation retries only fix frontmatter
@@ -144,9 +159,9 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
const primaryDetailHash = agentResult.detailHash; const primaryDetailHash = agentResult.detailHash;
// Try to extract frontmatter; retry via continue if it fails // Try to extract frontmatter; retry via continue if it fails
let outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx); let extracted = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && outputHash === null; retry++) { for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && extracted === null; retry++) {
const correctionMessage = const correctionMessage =
"Your previous response did not contain valid YAML frontmatter matching the role schema.\n" + "Your previous response did not contain valid YAML frontmatter matching the role schema.\n" +
"You MUST begin your response with a YAML frontmatter block (--- delimited).\n" + "You MUST begin your response with a YAML frontmatter block (--- delimited).\n" +
@@ -155,10 +170,11 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
agentResult = await runWithMessage("agent continue failed", () => agentResult = await runWithMessage("agent continue failed", () =>
options.continue(agentResult.sessionId, correctionMessage, ctx.meta.store), options.continue(agentResult.sessionId, correctionMessage, ctx.meta.store),
); );
outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx); agentResult.output = agentResult.output.trimStart();
extracted = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
} }
if (outputHash === null) { if (extracted === null) {
fail( fail(
"Agent output does not contain valid YAML frontmatter matching the role schema " + "Agent output does not contain valid YAML frontmatter matching the role schema " +
`after ${MAX_FRONTMATTER_RETRIES} retries.\n` + `after ${MAX_FRONTMATTER_RETRIES} retries.\n` +
@@ -168,13 +184,22 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
const completedAtMs = Date.now(); const completedAtMs = Date.now();
const stepHash = await persistStep({ const stepHash = await persistStep({
ctx, ctx,
outputHash, outputHash: extracted.outputHash,
detailHash: primaryDetailHash, detailHash: primaryDetailHash,
agentName: agentLabel(options.name), agentName: agentLabel(options.name),
startedAtMs, startedAtMs,
completedAtMs, completedAtMs,
}); });
process.stdout.write(`${stepHash}\n`); const adapterOutput: AdapterOutput = {
stepHash,
detailHash: primaryDetailHash,
role,
frontmatter: extracted.frontmatter,
body: extracted.body,
startedAtMs,
completedAtMs,
};
process.stdout.write(`${JSON.stringify(adapterOutput)}\n`);
}; };
} }
+10
View File
@@ -37,6 +37,16 @@ export type AgentContinueFn = (
export type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>; export type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
export type AdapterOutput = {
stepHash: string;
detailHash: string;
role: string;
frontmatter: Record<string, unknown>;
body: string;
startedAtMs: number;
completedAtMs: number;
};
export type AgentOptions = { export type AgentOptions = {
name: string; name: string;
run: AgentRunFn; run: AgentRunFn;
@@ -41,31 +41,13 @@ describe("parseFrontmatterMarkdown", () => {
}); });
}); });
describe("full frontmatter document", () => { describe("status-only frontmatter", () => {
it("parses all fields from a well-formed document", () => { it("parses status-only frontmatter", () => {
const raw = `--- const raw = "---\nstatus: done\n---\nbody";
status: done
next: reviewer
confidence: 0.9
artifacts:
- src/foo.ts
- src/bar.ts
scope: thread
---
## Summary
Everything looks good.`;
const result = parseFrontmatterMarkdown(raw); const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter).not.toBeNull(); expect(result.frontmatter).not.toBeNull();
const fm = result.frontmatter!; expect(result.frontmatter).toEqual({ status: "done" });
expect(fm.status).toBe("done"); expect(result.body).toBe("body");
expect(fm.next).toBe("reviewer");
expect(fm.confidence).toBe(0.9);
expect(fm.artifacts).toEqual(["src/foo.ts", "src/bar.ts"]);
expect(fm.scope).toBe("thread");
expect(result.body).toBe("## Summary\n\nEverything looks good.");
}); });
it("strips leading newline from body", () => { it("strips leading newline from body", () => {
@@ -87,6 +69,22 @@ Everything looks good.`;
}); });
}); });
describe("ignores legacy fields", () => {
it("legacy fields next/confidence/artifacts/scope are NOT present on result", () => {
const raw =
"---\nstatus: done\nnext: reviewer\nconfidence: 0.9\nartifacts:\n - src/foo.ts\nscope: thread\n---\n\nBody.";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter).not.toBeNull();
const fm = result.frontmatter!;
expect(fm.status).toBe("done");
// Legacy fields must not exist on the object at all
expect("next" in fm).toBe(false);
expect("confidence" in fm).toBe(false);
expect("artifacts" in fm).toBe(false);
expect("scope" in fm).toBe(false);
});
});
describe("status field", () => { describe("status field", () => {
it.each([ it.each([
"done", "done",
@@ -106,109 +104,18 @@ Everything looks good.`;
}); });
it("returns null status when omitted", () => { it("returns null status when omitted", () => {
const raw = "---\nconfidence: 0.5\n---\nbody"; const raw = "---\nfoo: bar\n---\nbody";
const result = parseFrontmatterMarkdown(raw); const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.status).toBeNull(); expect(result.frontmatter?.status).toBeNull();
}); });
}); });
describe("confidence field", () => {
it("parses integer as number", () => {
const raw = "---\nconfidence: 1\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.confidence).toBe(1);
});
it("parses decimal", () => {
const raw = "---\nconfidence: 0.75\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.confidence).toBe(0.75);
});
it("returns null when omitted", () => {
const raw = "---\nstatus: done\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.confidence).toBeNull();
});
it("returns null for non-numeric value", () => {
const raw = "---\nconfidence: high\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.confidence).toBeNull();
});
});
describe("artifacts field", () => {
it("parses block sequence", () => {
const raw = "---\nartifacts:\n - a.ts\n - b.ts\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.artifacts).toEqual(["a.ts", "b.ts"]);
});
it("parses inline sequence", () => {
const raw = "---\nartifacts: [a.ts, b.ts]\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.artifacts).toEqual(["a.ts", "b.ts"]);
});
it("returns empty array when omitted", () => {
const raw = "---\nstatus: done\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.artifacts).toEqual([]);
});
it("wraps single scalar in array", () => {
const raw = "---\nartifacts: only-one.ts\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.artifacts).toEqual(["only-one.ts"]);
});
});
describe("scope field", () => {
it('parses scope "role"', () => {
const raw = "---\nscope: role\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.scope).toBe("role");
});
it('parses scope "thread"', () => {
const raw = "---\nscope: thread\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.scope).toBe("thread");
});
it('defaults to "role" when omitted', () => {
const raw = "---\nstatus: done\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.scope).toBe("role");
});
it('defaults to "role" for unknown scope value', () => {
const raw = "---\nscope: global\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.scope).toBe("role");
});
});
describe("next field", () => {
it("parses a role name", () => {
const raw = "---\nnext: planner\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.next).toBe("planner");
});
it("returns null when omitted", () => {
const raw = "---\nstatus: done\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.next).toBeNull();
});
});
describe("unknown fields", () => { describe("unknown fields", () => {
it("ignores unknown keys silently", () => { it("ignores unknown keys silently", () => {
const raw = "---\nunknown_field: some_value\nstatus: done\n---\nbody"; const raw = "---\nunknown_field: some_value\nstatus: done\n---\nbody";
const result = parseFrontmatterMarkdown(raw); const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.status).toBe("done"); expect(result.frontmatter?.status).toBe("done");
expect(Object.keys(result.frontmatter!)).toEqual(["status"]);
}); });
}); });
@@ -221,123 +128,58 @@ Everything looks good.`;
}); });
describe("empty frontmatter block", () => { describe("empty frontmatter block", () => {
it("parses empty frontmatter and uses all defaults", () => { it("parses empty frontmatter with status null", () => {
const raw = "---\n---\nbody"; const raw = "---\n---\nbody";
const result = parseFrontmatterMarkdown(raw); const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter).not.toBeNull(); expect(result.frontmatter).not.toBeNull();
const fm = result.frontmatter!; const fm = result.frontmatter!;
expect(fm.status).toBeNull(); expect(fm.status).toBeNull();
expect(fm.next).toBeNull(); expect(Object.keys(fm)).toEqual(["status"]);
expect(fm.confidence).toBeNull();
expect(fm.artifacts).toEqual([]);
expect(fm.scope).toBe("role");
expect(result.body).toBe("body"); expect(result.body).toBe("body");
}); });
}); });
describe("AgentFrontmatter has exactly one field", () => {
it("has only status key", () => {
const fm: AgentFrontmatter = { status: null };
expect(Object.keys(fm)).toEqual(["status"]);
});
});
describe("FrontmatterValidationError only has status variant", () => {
it("status variant is valid", () => {
const err: import("../src/index.js").FrontmatterValidationError = {
field: "status",
message: "test",
};
expect(err.field).toBe("status");
});
});
}); });
// ── validateFrontmatter ────────────────────────────────────────────────────── // ── validateFrontmatter ──────────────────────────────────────────────────────
function validFm(overrides: Partial<AgentFrontmatter> = {}): AgentFrontmatter {
return {
status: "done",
next: null,
confidence: null,
artifacts: [],
scope: "role",
...overrides,
};
}
describe("validateFrontmatter", () => { describe("validateFrontmatter", () => {
it("returns no errors for a fully valid frontmatter", () => { it("returns no errors for a valid status", () => {
const errors = validateFrontmatter(validFm()); const errors = validateFrontmatter({ status: "done" });
expect(errors).toHaveLength(0); expect(errors).toHaveLength(0);
}); });
it("returns no errors when all nullable fields are null", () => { it("returns no errors when status is null", () => {
const fm: AgentFrontmatter = { const errors = validateFrontmatter({ status: null });
status: null, expect(errors).toHaveLength(0);
next: null, });
confidence: null,
artifacts: [], it("returns error for invalid status", () => {
scope: "role", const errors = validateFrontmatter({ status: "bogus" as never });
}; expect(errors).toHaveLength(1);
expect(errors[0]?.field).toBe("status");
});
it("no validation for next/confidence/artifacts/scope — fields do not exist", () => {
// AgentFrontmatter only has status — verify at runtime
const fm: AgentFrontmatter = { status: "done" };
expect(Object.keys(fm)).toEqual(["status"]);
expect(validateFrontmatter(fm)).toHaveLength(0); expect(validateFrontmatter(fm)).toHaveLength(0);
}); });
describe("confidence validation", () => {
it("accepts 0.0", () => {
expect(validateFrontmatter(validFm({ confidence: 0 }))).toHaveLength(0);
});
it("accepts 1.0", () => {
expect(validateFrontmatter(validFm({ confidence: 1 }))).toHaveLength(0);
});
it("rejects value below 0", () => {
const errors = validateFrontmatter(validFm({ confidence: -0.1 }));
expect(errors).toHaveLength(1);
expect(errors[0]?.field).toBe("confidence");
});
it("rejects value above 1", () => {
const errors = validateFrontmatter(validFm({ confidence: 1.01 }));
expect(errors).toHaveLength(1);
expect(errors[0]?.field).toBe("confidence");
});
});
describe("next validation", () => {
it("accepts a simple role name", () => {
expect(validateFrontmatter(validFm({ next: "reviewer" }))).toHaveLength(0);
});
it("accepts kebab-case role name", () => {
expect(validateFrontmatter(validFm({ next: "code-reviewer" }))).toHaveLength(0);
});
it("rejects role name with whitespace", () => {
const errors = validateFrontmatter(validFm({ next: "role name" }));
expect(errors).toHaveLength(1);
expect(errors[0]?.field).toBe("next");
});
});
describe("artifacts validation", () => {
it("accepts non-empty path strings", () => {
expect(
validateFrontmatter(validFm({ artifacts: ["src/foo.ts", "src/bar.ts"] })),
).toHaveLength(0);
});
it("rejects empty string artifact entries", () => {
const errors = validateFrontmatter(validFm({ artifacts: [""] }));
expect(errors).toHaveLength(1);
expect(errors[0]?.field).toBe("artifacts");
});
it("rejects whitespace-only artifact entries", () => {
const errors = validateFrontmatter(validFm({ artifacts: [" "] }));
expect(errors).toHaveLength(1);
expect(errors[0]?.field).toBe("artifacts");
});
});
describe("multiple errors", () => {
it("reports multiple violations at once", () => {
const fm: AgentFrontmatter = {
status: "done",
next: "bad role",
confidence: 2,
artifacts: [""],
scope: "role",
};
const errors = validateFrontmatter(fm);
const fields = errors.map((e) => e.field);
expect(fields).toContain("next");
expect(fields).toContain("confidence");
expect(fields).toContain("artifacts");
});
});
}); });
@@ -1,6 +1,5 @@
import type { import type {
AgentFrontmatter, AgentFrontmatter,
FrontmatterScope,
FrontmatterStatus, FrontmatterStatus,
FrontmatterValidationError, FrontmatterValidationError,
ParsedFrontmatterMarkdown, ParsedFrontmatterMarkdown,
@@ -159,40 +158,12 @@ function parseMinimalYaml(yaml: string): Record<string, YamlValue> {
const VALID_STATUS: readonly FrontmatterStatus[] = ["done", "needs_input", "in_progress", "failed"]; const VALID_STATUS: readonly FrontmatterStatus[] = ["done", "needs_input", "in_progress", "failed"];
const VALID_SCOPE: readonly FrontmatterScope[] = ["role", "thread"];
function coerceStatus(raw: YamlValue): FrontmatterStatus | null { function coerceStatus(raw: YamlValue): FrontmatterStatus | null {
if (raw === null || raw === undefined) return null; if (raw === null || raw === undefined) return null;
const s = String(raw).trim().toLowerCase(); const s = String(raw).trim().toLowerCase();
return VALID_STATUS.includes(s as FrontmatterStatus) ? (s as FrontmatterStatus) : null; return VALID_STATUS.includes(s as FrontmatterStatus) ? (s as FrontmatterStatus) : null;
} }
function coerceNext(raw: YamlValue): string | null {
if (raw === null || raw === undefined) return null;
const s = String(raw).trim();
return s === "" ? null : s;
}
function coerceConfidence(raw: YamlValue): number | null {
if (raw === null || raw === undefined) return null;
const n = typeof raw === "number" ? raw : Number(String(raw).trim());
if (Number.isNaN(n)) return null;
return n;
}
function coerceArtifacts(raw: YamlValue): readonly string[] {
if (raw === null || raw === undefined) return [];
if (Array.isArray(raw)) return raw.map(String).filter((s) => s !== "");
const s = String(raw).trim();
return s === "" ? [] : [s];
}
function coerceScope(raw: YamlValue): FrontmatterScope {
if (raw === null || raw === undefined) return "role";
const s = String(raw).trim().toLowerCase();
return VALID_SCOPE.includes(s as FrontmatterScope) ? (s as FrontmatterScope) : "role";
}
// ── Public API ─────────────────────────────────────────────────────────────── // ── Public API ───────────────────────────────────────────────────────────────
/** /**
@@ -220,10 +191,6 @@ export function parseFrontmatterMarkdown(raw: string): ParsedFrontmatterMarkdown
const frontmatter: AgentFrontmatter = { const frontmatter: AgentFrontmatter = {
status: coerceStatus(fields.status ?? null), status: coerceStatus(fields.status ?? null),
next: coerceNext(fields.next ?? null),
confidence: coerceConfidence(fields.confidence ?? null),
artifacts: coerceArtifacts(fields.artifacts ?? null),
scope: coerceScope(fields.scope ?? null),
}; };
return { frontmatter, body }; return { frontmatter, body };
@@ -235,11 +202,7 @@ export function parseFrontmatterMarkdown(raw: string): ParsedFrontmatterMarkdown
* An empty array means the frontmatter is valid. * An empty array means the frontmatter is valid.
* *
* Validated constraints: * Validated constraints:
* - `status` — must be one of the FrontmatterStatus literals (if non-null) * - `status` — must be one of the FrontmatterStatus literals (if non-null)
* - `confidence` — must be in [0.0, 1.0] (if non-null)
* - `next` — must be a non-empty string with no whitespace (if non-null)
* - `artifacts` — each entry must be a non-empty string
* - `scope` — must be one of the FrontmatterScope literals
*/ */
export function validateFrontmatter( export function validateFrontmatter(
frontmatter: AgentFrontmatter, frontmatter: AgentFrontmatter,
@@ -253,39 +216,5 @@ export function validateFrontmatter(
}); });
} }
if (frontmatter.confidence !== null) {
if (frontmatter.confidence < 0 || frontmatter.confidence > 1) {
errors.push({
field: "confidence",
message: `confidence ${frontmatter.confidence} is out of range; must be between 0.0 and 1.0 inclusive`,
});
}
}
if (frontmatter.next !== null) {
if (frontmatter.next.trim() === "") {
errors.push({ field: "next", message: "next must be a non-empty string when present" });
} else if (/\s/.test(frontmatter.next)) {
errors.push({
field: "next",
message: `next "${frontmatter.next}" must not contain whitespace`,
});
}
}
for (const artifact of frontmatter.artifacts) {
if (artifact.trim() === "") {
errors.push({ field: "artifacts", message: "artifact entries must be non-empty strings" });
break;
}
}
if (!VALID_SCOPE.includes(frontmatter.scope)) {
errors.push({
field: "scope",
message: `invalid scope "${frontmatter.scope}"; must be one of: ${VALID_SCOPE.join(", ")}`,
});
}
return errors; return errors;
} }
@@ -1,7 +1,6 @@
export { parseFrontmatterMarkdown, validateFrontmatter } from "./frontmatter-markdown.js"; export { parseFrontmatterMarkdown, validateFrontmatter } from "./frontmatter-markdown.js";
export type { export type {
AgentFrontmatter, AgentFrontmatter,
FrontmatterScope,
FrontmatterStatus, FrontmatterStatus,
FrontmatterValidationError, FrontmatterValidationError,
ParsedFrontmatterMarkdown, ParsedFrontmatterMarkdown,
@@ -1,5 +1,5 @@
/** /**
* Frontmatter Markdown — agent output format (RFC #351 Phase 1). * Frontmatter Markdown — agent output format.
* *
* An agent response is a Markdown document with an optional YAML frontmatter * An agent response is a Markdown document with an optional YAML frontmatter
* block at the top. The frontmatter carries structured signals that the * block at the top. The frontmatter carries structured signals that the
@@ -9,17 +9,12 @@
* *
* --- * ---
* status: done * status: done
* next: reviewer
* confidence: 0.9
* artifacts:
* - src/foo.ts
* scope: role
* --- * ---
* *
* ... free-form markdown body ... * ... free-form markdown body ...
* *
* All frontmatter fields are optional at the parse level. `validateFrontmatter` * Only `status` is a standard frontmatter field. All other fields are
* enforces the constraints documented on each field below. * role-specific and defined by the output schema.
*/ */
// ── Vocabulary types ───────────────────────────────────────────────────────── // ── Vocabulary types ─────────────────────────────────────────────────────────
@@ -34,20 +29,12 @@
*/ */
export type FrontmatterStatus = "done" | "needs_input" | "in_progress" | "failed"; export type FrontmatterStatus = "done" | "needs_input" | "in_progress" | "failed";
/**
* Scope of frontmatter signals.
*
* - `role` — signals apply to the current role execution only (default)
* - `thread` — signals are suggestions for the entire thread moderator
*/
export type FrontmatterScope = "role" | "thread";
// ── Core frontmatter schema ────────────────────────────────────────────────── // ── Core frontmatter schema ──────────────────────────────────────────────────
/** /**
* Parsed and validated frontmatter from an agent response. * Parsed and validated frontmatter from an agent response.
* *
* All fields use explicit `T | null` (no optional `?:` per convention). * Only `status` is a standard field. All other fields are role-specific.
*/ */
export type AgentFrontmatter = { export type AgentFrontmatter = {
/** /**
@@ -55,32 +42,6 @@ export type AgentFrontmatter = {
* Null when omitted — engine treats it as "done" for backward compatibility. * Null when omitted — engine treats it as "done" for backward compatibility.
*/ */
status: FrontmatterStatus | null; status: FrontmatterStatus | null;
/**
* Suggested next role name for the moderator.
* The moderator is NOT obligated to follow this — it is advisory only.
* Null when the agent has no preference.
*/
next: string | null;
/**
* Agent's self-assessed confidence in its output (0.0 – 1.0 inclusive).
* Null when omitted.
*/
confidence: number | null;
/**
* Relative file paths or CAS hashes the agent considers its primary outputs.
* Used for GC ref-tracing and human-readable summaries.
* Empty array when omitted (never null — an absent list is an empty list).
*/
artifacts: readonly string[];
/**
* Scope of the frontmatter signals.
* Defaults to "role" when omitted.
*/
scope: FrontmatterScope;
}; };
// ── Parse output ───────────────────────────────────────────────────────────── // ── Parse output ─────────────────────────────────────────────────────────────
@@ -103,9 +64,4 @@ export type ParsedFrontmatterMarkdown = {
// ── Validation error ───────────────────────────────────────────────────────── // ── Validation error ─────────────────────────────────────────────────────────
export type FrontmatterValidationError = export type FrontmatterValidationError = { field: "status"; message: string };
| { field: "status"; message: string }
| { field: "next"; message: string }
| { field: "confidence"; message: string }
| { field: "artifacts"; message: string }
| { field: "scope"; message: string };
-1
View File
@@ -8,7 +8,6 @@ export { generateDeveloperReference } from "./developer-reference.js";
export { env } from "./env.js"; export { env } from "./env.js";
export type { export type {
AgentFrontmatter, AgentFrontmatter,
FrontmatterScope,
FrontmatterStatus, FrontmatterStatus,
FrontmatterValidationError, FrontmatterValidationError,
ParsedFrontmatterMarkdown, ParsedFrontmatterMarkdown,