Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96ab097198 | |||
| b95bbae5fc | |||
| cfa890f83c | |||
| 81c08ac7e2 | |||
| fdcfcc7eba | |||
| 4972f99ca0 | |||
| d8cba5eea0 | |||
| d9f7648fdd | |||
| a2e9dd9785 |
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/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 {
|
||||
cmdCasGet,
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
cmdThreadStart,
|
||||
cmdThreadStop,
|
||||
THREAD_READ_DEFAULT_QUOTA,
|
||||
type ThreadStatus,
|
||||
} from "./commands/thread.js";
|
||||
import { parseTimeInput } from "./commands/thread-time-parser.js";
|
||||
import { cmdWorkflowAdd, cmdWorkflowList, cmdWorkflowShow } from "./commands/workflow.js";
|
||||
@@ -118,10 +117,17 @@ thread
|
||||
.description("Create a thread without executing")
|
||||
.argument("<workflow>", "Workflow name or hash")
|
||||
.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();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
StepOutput,
|
||||
ThreadId,
|
||||
ThreadListItem,
|
||||
ThreadStatus,
|
||||
ThreadsIndex,
|
||||
WorkflowConfig,
|
||||
WorkflowPayload,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
generateUlid,
|
||||
type ProcessLogger,
|
||||
} from "@uncaged/workflow-util";
|
||||
import type { AdapterOutput } from "@uncaged/workflow-util-agent";
|
||||
import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-util-agent";
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
import { parse } from "yaml";
|
||||
@@ -315,10 +317,16 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
|
||||
if (workflow === null) {
|
||||
fail(`failed to resolve workflow from head: ${activeHead}`);
|
||||
}
|
||||
|
||||
// Check if thread is running
|
||||
const runningMarker = await isThreadRunning(storageRoot, threadId);
|
||||
const status: ThreadStatus = runningMarker !== null ? "running" : "idle";
|
||||
|
||||
return {
|
||||
workflow,
|
||||
thread: threadId,
|
||||
head: activeHead,
|
||||
status,
|
||||
done: false,
|
||||
background: null,
|
||||
};
|
||||
@@ -326,10 +334,13 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
|
||||
|
||||
const hist = await findThreadInHistory(storageRoot, threadId);
|
||||
if (hist !== null) {
|
||||
const status: ThreadStatus = hist.reason === "cancelled" ? "cancelled" : "completed";
|
||||
|
||||
return {
|
||||
workflow: hist.workflow,
|
||||
thread: threadId,
|
||||
head: hist.head,
|
||||
status,
|
||||
done: true,
|
||||
background: null,
|
||||
};
|
||||
@@ -338,8 +349,6 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
|
||||
fail(`thread not found: ${threadId}`);
|
||||
}
|
||||
|
||||
export type ThreadStatus = "idle" | "running" | "completed" | "cancelled";
|
||||
|
||||
export type ThreadListItemWithStatus = ThreadListItem & {
|
||||
status: ThreadStatus;
|
||||
};
|
||||
@@ -780,7 +789,7 @@ function spawnAgent(
|
||||
role: string,
|
||||
edgePrompt: string,
|
||||
cwd: string,
|
||||
): CasRef {
|
||||
): AdapterOutput {
|
||||
const argv = [...agent.args, "--thread", threadId, "--role", role, "--prompt", edgePrompt];
|
||||
let stdout: string;
|
||||
try {
|
||||
@@ -803,10 +812,22 @@ function spawnAgent(
|
||||
}
|
||||
|
||||
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
|
||||
if (!isCasRef(line)) {
|
||||
failStep(plog, `agent stdout is not a valid CAS hash: ${line || "(empty)"}`);
|
||||
let parsed: unknown;
|
||||
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(
|
||||
@@ -947,6 +968,7 @@ async function cmdThreadStepBackground(
|
||||
workflow: workflowHash,
|
||||
thread: threadId,
|
||||
head: headHash,
|
||||
status: "running",
|
||||
done: false,
|
||||
background: true,
|
||||
},
|
||||
@@ -989,6 +1011,7 @@ async function cmdThreadStepOnce(
|
||||
workflow: workflowHash,
|
||||
thread: threadId,
|
||||
head: headHash,
|
||||
status: "completed",
|
||||
done: true,
|
||||
background: null,
|
||||
};
|
||||
@@ -1009,7 +1032,8 @@ async function cmdThreadStepOnce(
|
||||
});
|
||||
|
||||
loadDotenv({ path: getEnvPath(storageRoot) });
|
||||
const newHead = spawnAgent(plog, agent, threadId, role, edgePrompt, effectiveCwd);
|
||||
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);
|
||||
|
||||
@@ -1041,10 +1065,14 @@ async function cmdThreadStepOnce(
|
||||
await archiveThread(storageRoot, threadId, workflowHash, newHead);
|
||||
}
|
||||
|
||||
// Determine status based on whether thread is done and running state
|
||||
const status: ThreadStatus = done ? "completed" : "idle";
|
||||
|
||||
return {
|
||||
workflow: workflowHash,
|
||||
thread: threadId,
|
||||
head: newHead,
|
||||
status,
|
||||
done,
|
||||
background: null,
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ export type {
|
||||
ThreadForkOutput,
|
||||
ThreadId,
|
||||
ThreadListItem,
|
||||
ThreadStatus,
|
||||
ThreadStepsOutput,
|
||||
ThreadsIndex,
|
||||
WorkflowConfig,
|
||||
|
||||
@@ -76,17 +76,27 @@ export type ModeratorContext = {
|
||||
|
||||
// ── 4.5 CLI 输出 ────────────────────────────────────────────────────
|
||||
|
||||
/** Thread status — unified status representation */
|
||||
export type ThreadStatus = "idle" | "running" | "completed" | "cancelled";
|
||||
|
||||
/** uwf thread start */
|
||||
export type StartOutput = {
|
||||
workflow: CasRef;
|
||||
thread: ThreadId;
|
||||
};
|
||||
|
||||
/** 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 = {
|
||||
workflow: CasRef;
|
||||
thread: ThreadId;
|
||||
head: CasRef;
|
||||
status: ThreadStatus;
|
||||
done: boolean;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ type StandardKey = (typeof STANDARD_KEYS)[number];
|
||||
export type FrontmatterFastPathResult = {
|
||||
body: string;
|
||||
outputHash: CasRef;
|
||||
frontmatter: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function extractYamlBlock(raw: string): string | null {
|
||||
@@ -191,5 +192,5 @@ export async function tryFrontmatterFastPath(
|
||||
return null;
|
||||
}
|
||||
|
||||
return { body, outputHash };
|
||||
return { body, outputHash, frontmatter: candidate };
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export { createAgent, parseArgv } from "./run.js";
|
||||
export { getCachedSessionId, getCachePath, setCachedSessionId } from "./session-cache.js";
|
||||
export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
|
||||
export type {
|
||||
AdapterOutput,
|
||||
AgentContext,
|
||||
AgentContinueFn,
|
||||
AgentOptions,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { buildContextWithMeta } from "./context.js";
|
||||
import { tryFrontmatterFastPath } from "./frontmatter.js";
|
||||
import type { AgentStore } 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;
|
||||
|
||||
@@ -85,14 +85,24 @@ async function writeStepNode(options: {
|
||||
return hash;
|
||||
}
|
||||
|
||||
type ExtractedOutput = {
|
||||
outputHash: CasRef;
|
||||
frontmatter: Record<string, unknown>;
|
||||
body: string;
|
||||
};
|
||||
|
||||
async function tryExtractOutput(
|
||||
rawOutput: string,
|
||||
outputSchema: CasRef,
|
||||
ctx: Awaited<ReturnType<typeof buildContextWithMeta>>,
|
||||
): Promise<CasRef | null> {
|
||||
): Promise<ExtractedOutput | null> {
|
||||
const fastPath = await tryFrontmatterFastPath(rawOutput, outputSchema, ctx.meta.store);
|
||||
if (fastPath !== null) {
|
||||
return fastPath.outputHash;
|
||||
return {
|
||||
outputHash: fastPath.outputHash,
|
||||
frontmatter: fastPath.frontmatter,
|
||||
body: fastPath.body,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -148,9 +158,9 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
const primaryDetailHash = agentResult.detailHash;
|
||||
|
||||
// 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 =
|
||||
"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" +
|
||||
@@ -159,10 +169,10 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
agentResult = await runWithMessage("agent continue failed", () =>
|
||||
options.continue(agentResult.sessionId, correctionMessage, ctx.meta.store),
|
||||
);
|
||||
outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
||||
extracted = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
||||
}
|
||||
|
||||
if (outputHash === null) {
|
||||
if (extracted === null) {
|
||||
fail(
|
||||
"Agent output does not contain valid YAML frontmatter matching the role schema " +
|
||||
`after ${MAX_FRONTMATTER_RETRIES} retries.\n` +
|
||||
@@ -172,13 +182,22 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
const completedAtMs = Date.now();
|
||||
const stepHash = await persistStep({
|
||||
ctx,
|
||||
outputHash,
|
||||
outputHash: extracted.outputHash,
|
||||
detailHash: primaryDetailHash,
|
||||
agentName: agentLabel(options.name),
|
||||
startedAtMs,
|
||||
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`);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,6 +37,16 @@ export type AgentContinueFn = (
|
||||
|
||||
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 = {
|
||||
name: string;
|
||||
run: AgentRunFn;
|
||||
|
||||
Reference in New Issue
Block a user