Compare commits

...

4 Commits

Author SHA1 Message Date
xiaoju 94c719870f feat: Phase 5 — React layer instrumentation
- json-cas-react-recorder.ts: writeReactSession stores full ReAct trace
- ReactTrace/ReactTurnTrace/ReactToolCallTrace types
- JsonCasAgentResult with optional react trace
- Engine integration: real react-session when trace provided, placeholder when null
- 10 new tests (29 total for json-cas engine), biome clean

Closes #301
小橘 <xiaoju@shazhou.work>
2026-05-18 02:47:18 +00:00
xiaoju 5af2d54e0f Merge pull request 'feat: Phase 4 — json-cas engine migration' (#300) from feat/294-phase4-engine-migration into main 2026-05-18 02:38:04 +00:00
xiaoju e01c08dacb feat: Phase 4 — json-cas engine (new engine alongside old)
- json-cas-engine.ts: new engine using json-cas Store + typed nodes
- json-cas-context.ts: build ThreadContext from thread-step chain
- json-cas-types.ts: engine types (JsonCasEngineIo, JsonCasAgentFn, etc.)
- thread-start/step/end/content nodes in json-cas format
- JSONata moderator via evaluateModerator
- react placeholder (Phase 5 will fill in)
- 21 tests passing, biome clean

Closes #299
小橘 <xiaoju@shazhou.work>
2026-05-18 02:37:05 +00:00
xiaoju f9d3d38008 Merge pull request 'feat: Phase 3 — workflow JSON definitions in CAS' (#298) from feat/294-phase3-workflow-json into main 2026-05-18 02:27:55 +00:00
28 changed files with 2536 additions and 1 deletions
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/index.js";
describe("packageDescriptor", () => {
test("has the correct package name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-cursor");
});
test("has a non-empty version string", () => {
expect(typeof packageDescriptor.version).toBe("string");
expect(packageDescriptor.version.length).toBeGreaterThan(0);
});
test("capabilities is a non-empty array of strings", () => {
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
for (const cap of packageDescriptor.capabilities) {
expect(typeof cap).toBe("string");
}
});
test("configSchema is an object with type 'object'", () => {
expect(typeof packageDescriptor.configSchema).toBe("object");
expect(packageDescriptor.configSchema.type).toBe("object");
});
test("configSchema requires 'command' and 'timeout'", () => {
const required = packageDescriptor.configSchema.required as string[];
expect(required).toContain("command");
expect(required).toContain("timeout");
});
test("configSchema properties include command, model, timeout, workspace", () => {
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
expect(props).toHaveProperty("command");
expect(props).toHaveProperty("model");
expect(props).toHaveProperty("timeout");
expect(props).toHaveProperty("workspace");
});
});
@@ -11,6 +11,7 @@ import { extractWorkspacePath } from "./extract-workspace.js";
import type { CursorAgentConfig } from "./types.js";
import { validateCursorAgentConfig } from "./validate-config.js";
export { packageDescriptor } from "./package-descriptor.js";
export type { CursorAgentConfig } from "./types.js";
export { validateCursorAgentConfig } from "./validate-config.js";
@@ -0,0 +1,34 @@
import type { PackageDescriptor } from "@uncaged/workflow-protocol";
/**
* Static metadata for @uncaged/workflow-agent-cursor.
* Config maps to {@link CursorAgentConfig}.
*/
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-cursor",
version: "0.5.0-alpha.4",
capabilities: ["cursor-cli", "workspace-agent"],
configSchema: {
type: "object",
required: ["command", "timeout"],
properties: {
command: {
type: "string",
description: "Absolute path to the cursor-agent CLI binary.",
},
model: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Model identifier passed to cursor-agent --model; null means auto.",
},
timeout: {
type: "number",
description: "Timeout in milliseconds; 0 means no limit.",
},
workspace: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Override workspace path; null resolves from thread context.",
},
},
additionalProperties: false,
},
};
@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/index.js";
describe("packageDescriptor", () => {
test("has the correct package name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-hermes");
});
test("has a non-empty version string", () => {
expect(typeof packageDescriptor.version).toBe("string");
expect(packageDescriptor.version.length).toBeGreaterThan(0);
});
test("capabilities is a non-empty array of strings", () => {
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
for (const cap of packageDescriptor.capabilities) {
expect(typeof cap).toBe("string");
}
});
test("configSchema is an object with type 'object'", () => {
expect(typeof packageDescriptor.configSchema).toBe("object");
expect(packageDescriptor.configSchema.type).toBe("object");
});
test("configSchema requires 'command'", () => {
const required = packageDescriptor.configSchema.required as string[];
expect(required).toContain("command");
});
test("configSchema properties include command, model, timeout", () => {
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
expect(props).toHaveProperty("command");
expect(props).toHaveProperty("model");
expect(props).toHaveProperty("timeout");
});
});
@@ -13,6 +13,7 @@ const HERMES_DEFAULT_MAX_TURNS = 90;
type HermesAgentOpt = { prompt: string };
export { packageDescriptor } from "./package-descriptor.js";
export type { HermesAgentConfig } from "./types.js";
export { validateHermesAgentConfig } from "./validate-config.js";
@@ -0,0 +1,30 @@
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
/**
* Static metadata for @uncaged/workflow-agent-hermes.
* Config maps to {@link HermesAgentConfig}.
*/
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-hermes",
version: "0.5.0-alpha.4",
capabilities: ["hermes-cli", "yolo-mode"],
configSchema: {
type: "object",
required: ["command"],
properties: {
command: {
type: "string",
description: "Absolute path to the hermes CLI binary.",
},
model: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Model identifier passed to hermes --model; null uses the CLI default.",
},
timeout: {
anyOf: [{ type: "number" }, { type: "null" }],
description: "Timeout in milliseconds; null means no limit.",
},
},
additionalProperties: false,
},
};
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/index.js";
describe("packageDescriptor", () => {
test("has the correct package name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-llm");
});
test("has a non-empty version string", () => {
expect(typeof packageDescriptor.version).toBe("string");
expect(packageDescriptor.version.length).toBeGreaterThan(0);
});
test("capabilities is a non-empty array of strings", () => {
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
for (const cap of packageDescriptor.capabilities) {
expect(typeof cap).toBe("string");
}
});
test("configSchema is an object with type 'object'", () => {
expect(typeof packageDescriptor.configSchema).toBe("object");
expect(packageDescriptor.configSchema.type).toBe("object");
});
test("configSchema requires baseUrl, apiKey, model", () => {
const required = packageDescriptor.configSchema.required as string[];
expect(required).toContain("baseUrl");
expect(required).toContain("apiKey");
expect(required).toContain("model");
});
test("configSchema properties include baseUrl, apiKey, model", () => {
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
expect(props).toHaveProperty("baseUrl");
expect(props).toHaveProperty("apiKey");
expect(props).toHaveProperty("model");
});
});
+1
View File
@@ -4,3 +4,4 @@ export {
type LlmChatError,
type LlmMessage,
} from "./create-llm-adapter.js";
export { packageDescriptor } from "./package-descriptor.js";
@@ -0,0 +1,30 @@
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
/**
* Static metadata for @uncaged/workflow-agent-llm.
* Config maps to {@link LlmProvider}: baseUrl + apiKey + model.
*/
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-llm",
version: "0.5.0-alpha.4",
capabilities: ["llm-single-turn"],
configSchema: {
type: "object",
required: ["baseUrl", "apiKey", "model"],
properties: {
baseUrl: {
type: "string",
description: "Base URL of the OpenAI-compatible chat completions endpoint.",
},
apiKey: {
type: "string",
description: "API key for the provider.",
},
model: {
type: "string",
description: "Model identifier passed as the `model` field in the request body.",
},
},
additionalProperties: false,
},
};
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/index.js";
describe("packageDescriptor", () => {
test("has the correct package name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-react");
});
test("has a non-empty version string", () => {
expect(typeof packageDescriptor.version).toBe("string");
expect(packageDescriptor.version.length).toBeGreaterThan(0);
});
test("capabilities is a non-empty array of strings", () => {
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
for (const cap of packageDescriptor.capabilities) {
expect(typeof cap).toBe("string");
}
});
test("configSchema is an object with type 'object'", () => {
expect(typeof packageDescriptor.configSchema).toBe("object");
expect(packageDescriptor.configSchema.type).toBe("object");
});
test("configSchema requires maxRounds", () => {
const required = packageDescriptor.configSchema.required as string[];
expect(required).toContain("maxRounds");
});
test("configSchema properties include maxRounds", () => {
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
expect(props).toHaveProperty("maxRounds");
});
});
@@ -1,4 +1,5 @@
export { createReactAdapter } from "./create-react-adapter.js";
export { packageDescriptor } from "./package-descriptor.js";
export type { ToolEntry, ToolHandler } from "./tools/index.js";
export { defaultToolHandler, defaultTools } from "./tools/index.js";
export type { ReactAdapterConfig, ReactToolHandler } from "./types.js";
@@ -0,0 +1,25 @@
import type { PackageDescriptor } from "@uncaged/workflow-protocol";
/**
* Static metadata for @uncaged/workflow-agent-react.
*
* Config represents the serializable subset of {@link ReactAdapterConfig}.
* The `llm` function and `toolHandler` are runtime constructs and are not
* stored in the CAS agent node; only `maxRounds` is serializable.
*/
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-react",
version: "0.5.0-alpha.4",
capabilities: ["react-loop", "tool-calling"],
configSchema: {
type: "object",
required: ["maxRounds"],
properties: {
maxRounds: {
type: "number",
description: "Maximum number of LLM ↔ tool-call rounds before the loop is terminated.",
},
},
additionalProperties: false,
},
};
@@ -0,0 +1,868 @@
import { describe, expect, test } from "bun:test";
import { createMemoryStore, type Store, walk } from "@uncaged/json-cas";
import {
type ContentPayload,
registerWorkflowSchemas,
type ThreadEndPayload,
type ThreadStartPayload,
type ThreadStepPayload,
type WorkflowSchemaHashes,
} from "@uncaged/json-cas-workflow";
import { registerWorkflow, type WorkflowInput } from "@uncaged/workflow-json-def";
import {
buildJsonCasThreadContext,
buildJsonCasThreadSnapshot,
readContentText,
} from "../src/engine/json-cas-context.js";
import { executeJsonCasThread } from "../src/engine/json-cas-engine.js";
import type {
JsonCasAgentFn,
JsonCasEngineIo,
JsonCasEngineOptions,
} from "../src/engine/json-cas-types.js";
// ── Test fixtures ─────────────────────────────────────────────────────
const START = "__start__";
const END = "__end__";
const SIMPLE_WORKFLOW: WorkflowInput = {
name: "test-simple",
description: "A simple two-role workflow for testing",
roles: {
planner: {
description: "Plans the work",
systemPrompt: "You are a planner.",
extractPrompt: "Extract planner output.",
schema: {
type: "object",
required: ["plan"],
properties: { plan: { type: "string" } },
},
},
coder: {
description: "Implements the plan",
systemPrompt: "You are a coder.",
extractPrompt: "Extract coder output.",
schema: {
type: "object",
required: ["code"],
properties: { code: { type: "string" } },
},
},
},
moderator: [
{ from: START, to: "planner", when: null },
{ from: "planner", to: "coder", when: null },
{ from: "coder", to: END, when: null },
],
};
const SINGLE_ROLE_WORKFLOW: WorkflowInput = {
name: "test-single",
description: "A single-role workflow",
roles: {
worker: {
description: "Does all the work",
systemPrompt: "You are a worker.",
extractPrompt: "Extract worker output.",
schema: {
type: "object",
required: ["result"],
properties: { result: { type: "string" } },
},
},
},
moderator: [
{ from: START, to: "worker", when: null },
{ from: "worker", to: END, when: null },
],
};
const CONDITIONAL_WORKFLOW: WorkflowInput = {
name: "test-conditional",
description: "A workflow with JSONata conditions",
roles: {
checker: {
description: "Checks the input",
systemPrompt: "You are a checker.",
extractPrompt: "Extract checker output.",
schema: {
type: "object",
required: ["status"],
properties: { status: { type: "string" } },
},
},
fixer: {
description: "Fixes issues",
systemPrompt: "You are a fixer.",
extractPrompt: "Extract fixer output.",
schema: {
type: "object",
required: ["fix"],
properties: { fix: { type: "string" } },
},
},
},
moderator: [
{ from: START, to: "checker", when: null },
{ from: "checker", to: END, when: "steps[-1].meta.status = 'ok'" },
{ from: "checker", to: "fixer", when: null },
{ from: "fixer", to: "checker", when: null },
],
};
function noLogger(): (tag: string, content: string) => void {
return () => {};
}
async function setupStore(): Promise<{
store: Store;
typeHashes: WorkflowSchemaHashes;
}> {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
return { store, typeHashes };
}
async function setupWorkflow(
store: Store,
typeHashes: WorkflowSchemaHashes,
workflowDef: WorkflowInput,
) {
const workflowHash = await registerWorkflow(store, typeHashes, workflowDef);
return { workflowHash };
}
function makeOptions(overrides: Partial<JsonCasEngineOptions> = {}): JsonCasEngineOptions {
return {
depth: 0,
parentThread: null,
signal: new AbortController().signal,
agents: {},
...overrides,
};
}
function makeIo(store: Store, typeHashes: WorkflowSchemaHashes, threadId: string): JsonCasEngineIo {
return { threadId, store, typeHashes };
}
/**
* A mock agent that returns a canned text and meta for each role.
*/
function createMockAgent(
responses: Record<string, { text: string; meta: Record<string, unknown> }>,
): JsonCasAgentFn {
return async (role, _systemPrompt, _snapshot) => {
const resp = responses[role];
if (resp === undefined) {
throw new Error(`mock agent: no response configured for role "${role}"`);
}
return { ...resp, react: null };
};
}
// ── Tests ─────────────────────────────────────────────────────────────
describe("executeJsonCasThread", () => {
describe("thread lifecycle", () => {
test("simple two-role workflow creates start, two steps, and end nodes", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
const agentFn = createMockAgent({
planner: { text: "I will plan", meta: { plan: "phase-1" } },
coder: { text: "I wrote code", meta: { code: "done" } },
});
const result = await executeJsonCasThread({
workflowHash,
input: "Build a widget",
moderatorRules: SIMPLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD01"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: null,
});
expect(result.returnCode).toBe(0);
expect(result.summary).toContain("END");
expect(result.rootHash).toBeTruthy();
const endNode = store.get(result.rootHash);
expect(endNode).not.toBeNull();
const endPayload = endNode!.payload as ThreadEndPayload;
expect(endPayload.returnCode).toBe(0);
expect(endPayload.start).toBeTruthy();
expect(endPayload.lastStep).toBeTruthy();
});
test("single-role workflow creates correct chain", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
const agentFn = createMockAgent({
worker: { text: "work done", meta: { result: "success" } },
});
const result = await executeJsonCasThread({
workflowHash,
input: "Do the thing",
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD02"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: null,
});
expect(result.returnCode).toBe(0);
const endNode = store.get(result.rootHash);
expect(endNode).not.toBeNull();
const endPayload = endNode!.payload as ThreadEndPayload;
const lastStepNode = store.get(endPayload.lastStep);
expect(lastStepNode).not.toBeNull();
const lastStepPayload = lastStepNode!.payload as ThreadStepPayload;
expect(lastStepPayload.role).toBe("worker");
expect(lastStepPayload.previous).toBeNull();
const startNode = store.get(endPayload.start);
expect(startNode).not.toBeNull();
const startPayload = startNode!.payload as ThreadStartPayload;
expect(startPayload.input).toBe("Do the thing");
expect(startPayload.depth).toBe(0);
});
});
describe("CAS node structure", () => {
test("thread-start contains workflow ref, input, depth, agents", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
const agentFn = createMockAgent({
worker: { text: "ok", meta: { result: "ok" } },
});
const agentHash = await store.put(typeHashes.agent, {
package: "test-agent",
version: "1.0.0",
config: {},
});
const result = await executeJsonCasThread({
workflowHash,
input: "Test input",
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD03"),
options: makeOptions({ agents: { worker: agentHash }, depth: 2 }),
agentFn,
logger: noLogger(),
workflow: null,
});
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
const startPayload = store.get(endPayload.start)!.payload as ThreadStartPayload;
expect(startPayload.workflow).toBe(workflowHash);
expect(startPayload.input).toBe("Test input");
expect(startPayload.depth).toBe(2);
expect(startPayload.parentThread).toBeNull();
expect(startPayload.agents).toEqual({ worker: agentHash });
});
test("thread-start records parentThread when provided", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
const agentFn = createMockAgent({
worker: { text: "nested", meta: { result: "nested" } },
});
const fakeParent = "FAKEPARENT0001";
const result = await executeJsonCasThread({
workflowHash,
input: "nested task",
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD04"),
options: makeOptions({ parentThread: fakeParent, depth: 1 }),
agentFn,
logger: noLogger(),
workflow: null,
});
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
const startPayload = store.get(endPayload.start)!.payload as ThreadStartPayload;
expect(startPayload.parentThread).toBe(fakeParent);
expect(startPayload.depth).toBe(1);
});
test("each thread-step has content, react, start, and previous refs", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
const agentFn = createMockAgent({
planner: { text: "plan text", meta: { plan: "p1" } },
coder: { text: "code text", meta: { code: "c1" } },
});
const result = await executeJsonCasThread({
workflowHash,
input: "go",
moderatorRules: SIMPLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD05"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: null,
});
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
const startHash = endPayload.start;
const step2 = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
expect(step2.role).toBe("coder");
expect(step2.start).toBe(startHash);
expect(step2.previous).not.toBeNull();
const contentNode2 = store.get(step2.content);
expect(contentNode2).not.toBeNull();
expect((contentNode2!.payload as ContentPayload).text).toBe("code text");
const reactNode2 = store.get(step2.react);
expect(reactNode2).not.toBeNull();
const step1 = store.get(step2.previous!)!.payload as ThreadStepPayload;
expect(step1.role).toBe("planner");
expect(step1.start).toBe(startHash);
expect(step1.previous).toBeNull();
const contentNode1 = store.get(step1.content);
expect(contentNode1).not.toBeNull();
expect((contentNode1!.payload as ContentPayload).text).toBe("plan text");
});
test("thread-end references start and last step", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
const agentFn = createMockAgent({
planner: { text: "plan", meta: { plan: "x" } },
coder: { text: "code", meta: { code: "x" } },
});
const result = await executeJsonCasThread({
workflowHash,
input: "test",
moderatorRules: SIMPLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD06"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: null,
});
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
expect(endPayload.returnCode).toBe(0);
expect(endPayload.summary).toBeTruthy();
const startNode = store.get(endPayload.start);
expect(startNode).not.toBeNull();
expect((startNode!.payload as ThreadStartPayload).workflow).toBe(workflowHash);
const lastStepNode = store.get(endPayload.lastStep);
expect(lastStepNode).not.toBeNull();
expect((lastStepNode!.payload as ThreadStepPayload).role).toBe("coder");
});
test("content nodes store the agent text verbatim", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
const longText = "This is a longer text with\nnewlines\nand special chars: <>&\"'";
const agentFn = createMockAgent({
worker: { text: longText, meta: { result: "done" } },
});
const result = await executeJsonCasThread({
workflowHash,
input: "process this",
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD07"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: null,
});
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
const stepPayload = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
const contentPayload = store.get(stepPayload.content)!.payload as ContentPayload;
expect(contentPayload.text).toBe(longText);
});
test("meta is stored in thread-step payload", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
const complexMeta = {
plan: "phase-1",
phases: [{ hash: "abc", title: "first" }],
nested: { deep: true },
};
const agentFn = createMockAgent({
planner: { text: "plan", meta: complexMeta },
coder: { text: "code", meta: { code: "done" } },
});
const result = await executeJsonCasThread({
workflowHash,
input: "go",
moderatorRules: SIMPLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD08"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: null,
});
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
const step2 = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
const step1 = store.get(step2.previous!)!.payload as ThreadStepPayload;
expect(step1.meta).toEqual(complexMeta);
expect(step2.meta).toEqual({ code: "done" });
});
});
describe("moderator routing", () => {
test("conditional moderator routes based on agent meta", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, CONDITIONAL_WORKFLOW);
let checkerCallCount = 0;
const agentFn: JsonCasAgentFn = async (role, _sp, _snap) => {
if (role === "checker") {
checkerCallCount++;
if (checkerCallCount === 1) {
return { text: "found issue", meta: { status: "bad" }, react: null };
}
return { text: "all good now", meta: { status: "ok" }, react: null };
}
return { text: "fixed it", meta: { fix: "patched" }, react: null };
};
const result = await executeJsonCasThread({
workflowHash,
input: "check and fix",
moderatorRules: CONDITIONAL_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD09"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: null,
});
expect(result.returnCode).toBe(0);
expect(checkerCallCount).toBe(2);
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
const lastStep = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
expect(lastStep.role).toBe("checker");
const step2 = store.get(lastStep.previous!)!.payload as ThreadStepPayload;
expect(step2.role).toBe("fixer");
const step1 = store.get(step2.previous!)!.payload as ThreadStepPayload;
expect(step1.role).toBe("checker");
expect(step1.previous).toBeNull();
});
test("immediate END from moderator still produces a valid thread", async () => {
const { store, typeHashes } = await setupStore();
const immediateEnd: WorkflowInput = {
name: "test-immediate-end",
description: "Ends immediately",
roles: {
worker: {
description: "Never called",
systemPrompt: "N/A",
extractPrompt: "N/A",
schema: { type: "object" },
},
},
moderator: [{ from: START, to: END, when: null }],
};
const { workflowHash } = await setupWorkflow(store, typeHashes, immediateEnd);
const agentFn: JsonCasAgentFn = async (): Promise<never> => {
throw new Error("should not be called");
};
const result = await executeJsonCasThread({
workflowHash,
input: "skip",
moderatorRules: immediateEnd.moderator,
io: makeIo(store, typeHashes, "THREAD10"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: null,
});
expect(result.returnCode).toBe(0);
const endNode = store.get(result.rootHash);
expect(endNode).not.toBeNull();
const endPayload = endNode!.payload as ThreadEndPayload;
expect(endPayload.start).toBeTruthy();
expect(endPayload.lastStep).toBeTruthy();
});
});
describe("abort handling", () => {
test("aborted signal produces returnCode 130", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
const ac = new AbortController();
ac.abort();
const agentFn: JsonCasAgentFn = async (): Promise<never> => {
throw new Error("should not be called");
};
const result = await executeJsonCasThread({
workflowHash,
input: "will abort",
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD11"),
options: makeOptions({ signal: ac.signal }),
agentFn,
logger: noLogger(),
workflow: null,
});
expect(result.returnCode).toBe(130);
expect(result.summary).toContain("abort");
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
expect(endPayload.returnCode).toBe(130);
});
});
describe("agent receives correct context", () => {
test("agent receives role name, system prompt, and accumulated steps", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
const { loadWorkflow } = await import("@uncaged/workflow-json-def");
const hydrated = loadWorkflow(store, typeHashes, workflowHash);
const receivedCalls: Array<{
role: string;
systemPrompt: string;
stepCount: number;
input: string;
}> = [];
const agentFn: JsonCasAgentFn = async (role, systemPrompt, snapshot) => {
receivedCalls.push({
role,
systemPrompt,
stepCount: snapshot.steps.length,
input: snapshot.start.input,
});
return { text: `output for ${role}`, meta: {}, react: null };
};
await executeJsonCasThread({
workflowHash,
input: "my prompt",
moderatorRules: SIMPLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD12"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: hydrated,
});
expect(receivedCalls.length).toBe(2);
expect(receivedCalls[0]!.role).toBe("planner");
expect(receivedCalls[0]!.systemPrompt).toBe("You are a planner.");
expect(receivedCalls[0]!.stepCount).toBe(0);
expect(receivedCalls[0]!.input).toBe("my prompt");
expect(receivedCalls[1]!.role).toBe("coder");
expect(receivedCalls[1]!.systemPrompt).toBe("You are a coder.");
expect(receivedCalls[1]!.stepCount).toBe(1);
});
test("snapshot accumulates step meta from previous rounds", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, CONDITIONAL_WORKFLOW);
let round = 0;
const snapshots: Array<{
role: string;
steps: readonly { role: string; meta: Record<string, unknown> }[];
}> = [];
const agentFn: JsonCasAgentFn = async (role, _sp, snapshot) => {
snapshots.push({ role, steps: [...snapshot.steps] });
round++;
if (role === "checker") {
return round === 1
? { text: "bad", meta: { status: "bad" }, react: null }
: { text: "ok", meta: { status: "ok" }, react: null };
}
return { text: "fixed", meta: { fix: "yes" }, react: null };
};
await executeJsonCasThread({
workflowHash,
input: "go",
moderatorRules: CONDITIONAL_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD13"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: null,
});
expect(snapshots.length).toBe(3);
expect(snapshots[0]!.steps.length).toBe(0);
expect(snapshots[1]!.steps.length).toBe(1);
expect(snapshots[1]!.steps[0]!.role).toBe("checker");
expect(snapshots[1]!.steps[0]!.meta).toEqual({ status: "bad" });
expect(snapshots[2]!.steps.length).toBe(2);
expect(snapshots[2]!.steps[0]!.role).toBe("checker");
expect(snapshots[2]!.steps[1]!.role).toBe("fixer");
});
});
});
describe("buildJsonCasThreadSnapshot", () => {
test("builds snapshot from start + step chain", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
const agentFn = createMockAgent({
planner: { text: "plan text", meta: { plan: "alpha" } },
coder: { text: "code text", meta: { code: "beta" } },
});
const result = await executeJsonCasThread({
workflowHash,
input: "build it",
moderatorRules: SIMPLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD_SNAP"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: null,
});
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
const startHash = endPayload.start;
const lastStepHash = endPayload.lastStep;
const snapshot = buildJsonCasThreadSnapshot(
store,
typeHashes,
startHash,
lastStepHash,
"THREAD_SNAP",
);
expect(snapshot.threadId).toBe("THREAD_SNAP");
expect(snapshot.start.input).toBe("build it");
expect(snapshot.start.workflowHash).toBe(workflowHash);
expect(snapshot.steps.length).toBe(2);
expect(snapshot.steps[0]!.role).toBe("planner");
expect(snapshot.steps[0]!.meta).toEqual({ plan: "alpha" });
expect(snapshot.steps[1]!.role).toBe("coder");
expect(snapshot.steps[1]!.meta).toEqual({ code: "beta" });
});
test("builds snapshot with null headStepHash (start only)", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
const startHash = await store.put(typeHashes.threadStart, {
workflow: workflowHash,
input: "just started",
depth: 0,
parentThread: null,
agents: {},
});
const snapshot = buildJsonCasThreadSnapshot(store, typeHashes, startHash, null, "THREAD_SNAP2");
expect(snapshot.threadId).toBe("THREAD_SNAP2");
expect(snapshot.start.input).toBe("just started");
expect(snapshot.steps.length).toBe(0);
});
});
describe("buildJsonCasThreadContext", () => {
test("builds a protocol-compatible ThreadContext", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
const agentFn = createMockAgent({
planner: { text: "plan text", meta: { plan: "ctx-test" } },
coder: { text: "code text", meta: { code: "ctx-done" } },
});
const result = await executeJsonCasThread({
workflowHash,
input: "context test",
moderatorRules: SIMPLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD_CTX"),
options: makeOptions({ depth: 3 }),
agentFn,
logger: noLogger(),
workflow: null,
});
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
const ctx = buildJsonCasThreadContext(store, typeHashes, endPayload.start, endPayload.lastStep);
expect(ctx.threadId).toBe("");
expect(ctx.depth).toBe(3);
expect(ctx.bundleHash).toBe(workflowHash);
expect(ctx.start.role).toBe("__start__");
expect(ctx.start.content).toBe("context test");
expect(ctx.steps.length).toBe(2);
expect(ctx.steps[0]!.role).toBe("planner");
expect(ctx.steps[0]!.meta).toEqual({ plan: "ctx-test" });
expect(ctx.steps[1]!.role).toBe("coder");
expect(ctx.steps[1]!.meta).toEqual({ code: "ctx-done" });
});
test("context from start-only thread has empty steps", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
const startHash = await store.put(typeHashes.threadStart, {
workflow: workflowHash,
input: "start only",
depth: 0,
parentThread: null,
agents: {},
});
const ctx = buildJsonCasThreadContext(store, typeHashes, startHash, null);
expect(ctx.start.content).toBe("start only");
expect(ctx.steps.length).toBe(0);
});
});
describe("readContentText", () => {
test("reads text from a content node", async () => {
const { store, typeHashes } = await setupStore();
const hash = await store.put(typeHashes.content, { text: "hello world" });
const text = readContentText(store, hash);
expect(text).toBe("hello world");
});
test("returns null for missing hash", async () => {
const { store } = await setupStore();
const text = readContentText(store, "NONEXISTENT0001");
expect(text).toBeNull();
});
});
describe("CAS graph integrity", () => {
test("all nodes are reachable via walk from thread-end", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
const agentFn = createMockAgent({
planner: { text: "plan", meta: { plan: "x" } },
coder: { text: "code", meta: { code: "y" } },
});
const result = await executeJsonCasThread({
workflowHash,
input: "walk test",
moderatorRules: SIMPLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD_WALK"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: null,
});
const visited = new Set<string>();
walk(store, result.rootHash, (hash) => {
visited.add(hash);
});
expect(visited.has(result.rootHash)).toBe(true);
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
expect(visited.has(endPayload.start)).toBe(true);
expect(visited.has(endPayload.lastStep)).toBe(true);
const step2 = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
expect(visited.has(step2.content)).toBe(true);
expect(visited.has(step2.react)).toBe(true);
expect(visited.has(step2.start)).toBe(true);
if (step2.previous !== null) {
expect(visited.has(step2.previous)).toBe(true);
const step1 = store.get(step2.previous)!.payload as ThreadStepPayload;
expect(visited.has(step1.content)).toBe(true);
expect(visited.has(step1.react)).toBe(true);
}
});
test("react session nodes have empty structure when agent returns react: null", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
const agentFn = createMockAgent({
worker: { text: "w", meta: { result: "r" } },
});
const result = await executeJsonCasThread({
workflowHash,
input: "react check",
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD_REACT"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: null,
});
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
const stepPayload = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
const reactNode = store.get(stepPayload.react);
expect(reactNode).not.toBeNull();
const reactPayload = reactNode!.payload as Record<string, unknown>;
expect(reactPayload.turns).toEqual([]);
expect(reactPayload.totalTokens).toBe(0);
expect(reactPayload.durationMs).toBe(0);
expect(reactPayload.role).toBe("worker");
expect(typeof reactPayload.agent).toBe("string");
});
});
@@ -0,0 +1,415 @@
import { describe, expect, test } from "bun:test";
import { createMemoryStore } from "@uncaged/json-cas";
import {
type ContentPayload,
type ReactSessionPayload,
type ReactToolCallPayload,
type ReactTurnPayload,
registerWorkflowSchemas,
} from "@uncaged/json-cas-workflow";
import { writeReactSession } from "../src/engine/json-cas-react-recorder.js";
import type { ReactTrace } from "../src/engine/json-cas-types.js";
// ── Fixtures ──────────────────────────────────────────────────────────
async function setupStore() {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
return { store, typeHashes };
}
async function makeFakeAgent(
store: Awaited<ReturnType<typeof setupStore>>["store"],
typeHashes: Awaited<ReturnType<typeof setupStore>>["typeHashes"],
) {
return store.put(typeHashes.agent, {
package: "test-agent",
version: "1.0.0",
config: {},
});
}
// ── Tests ─────────────────────────────────────────────────────────────
describe("writeReactSession", () => {
describe("empty trace", () => {
test("produces a react-session with zero turns", async () => {
const { store, typeHashes } = await setupStore();
const agentHash = await makeFakeAgent(store, typeHashes);
const trace: ReactTrace = { turns: [], totalTokens: 0, durationMs: 0 };
const sessionHash = await writeReactSession(store, typeHashes, {
agentHash,
role: "worker",
trace,
});
const node = store.get(sessionHash);
expect(node).not.toBeNull();
const payload = node!.payload as ReactSessionPayload;
expect(payload.agent).toBe(agentHash);
expect(payload.role).toBe("worker");
expect(payload.turns).toEqual([]);
expect(payload.totalTokens).toBe(0);
expect(payload.durationMs).toBe(0);
});
});
describe("single turn, no tool calls", () => {
test("produces react-session → react-turn → content nodes", async () => {
const { store, typeHashes } = await setupStore();
const agentHash = await makeFakeAgent(store, typeHashes);
const trace: ReactTrace = {
turns: [
{
input: "What is 2+2?",
output: "4",
toolCalls: [],
tokens: { input: 10, output: 5 },
latencyMs: 200,
},
],
totalTokens: 15,
durationMs: 200,
};
const sessionHash = await writeReactSession(store, typeHashes, {
agentHash,
role: "solver",
trace,
});
const session = store.get(sessionHash)!.payload as ReactSessionPayload;
expect(session.turns.length).toBe(1);
expect(session.totalTokens).toBe(15);
expect(session.durationMs).toBe(200);
expect(session.role).toBe("solver");
const turnHash = session.turns[0]!;
const turn = store.get(turnHash)!.payload as ReactTurnPayload;
expect(turn.toolCalls).toEqual([]);
expect(turn.tokens).toEqual({ input: 10, output: 5 });
expect(turn.latencyMs).toBe(200);
const inputContent = store.get(turn.input)!.payload as ContentPayload;
expect(inputContent.text).toBe("What is 2+2?");
const outputContent = store.get(turn.output)!.payload as ContentPayload;
expect(outputContent.text).toBe("4");
});
});
describe("single turn with tool calls", () => {
test("serialises tool calls to react-tool-call → content nodes", async () => {
const { store, typeHashes } = await setupStore();
const agentHash = await makeFakeAgent(store, typeHashes);
const trace: ReactTrace = {
turns: [
{
input: "Search for cats",
output: "Found 42 cats",
toolCalls: [
{
name: "search",
arguments: '{"query":"cats"}',
result: '{"count":42}',
durationMs: 80,
},
],
tokens: { input: 20, output: 10 },
latencyMs: 350,
},
],
totalTokens: 30,
durationMs: 350,
};
const sessionHash = await writeReactSession(store, typeHashes, {
agentHash,
role: "searcher",
trace,
});
const session = store.get(sessionHash)!.payload as ReactSessionPayload;
const turn = store.get(session.turns[0]!)!.payload as ReactTurnPayload;
expect(turn.toolCalls.length).toBe(1);
const toolCall = store.get(turn.toolCalls[0]!)!.payload as ReactToolCallPayload;
expect(toolCall.name).toBe("search");
expect(toolCall.durationMs).toBe(80);
const argsContent = store.get(toolCall.arguments)!.payload as ContentPayload;
expect(argsContent.text).toBe('{"query":"cats"}');
const resultContent = store.get(toolCall.result)!.payload as ContentPayload;
expect(resultContent.text).toBe('{"count":42}');
});
test("multiple tool calls in one turn are all recorded", async () => {
const { store, typeHashes } = await setupStore();
const agentHash = await makeFakeAgent(store, typeHashes);
const trace: ReactTrace = {
turns: [
{
input: "Do two things",
output: "Done",
toolCalls: [
{ name: "tool_a", arguments: '{"x":1}', result: '"ok_a"', durationMs: 10 },
{ name: "tool_b", arguments: '{"y":2}', result: '"ok_b"', durationMs: 20 },
],
tokens: { input: 5, output: 3 },
latencyMs: 100,
},
],
totalTokens: 8,
durationMs: 100,
};
const sessionHash = await writeReactSession(store, typeHashes, {
agentHash,
role: "doer",
trace,
});
const session = store.get(sessionHash)!.payload as ReactSessionPayload;
const turn = store.get(session.turns[0]!)!.payload as ReactTurnPayload;
expect(turn.toolCalls.length).toBe(2);
const tc0 = store.get(turn.toolCalls[0]!)!.payload as ReactToolCallPayload;
expect(tc0.name).toBe("tool_a");
const tc1 = store.get(turn.toolCalls[1]!)!.payload as ReactToolCallPayload;
expect(tc1.name).toBe("tool_b");
});
});
describe("multiple turns", () => {
test("each turn is stored as a separate react-turn node", async () => {
const { store, typeHashes } = await setupStore();
const agentHash = await makeFakeAgent(store, typeHashes);
const trace: ReactTrace = {
turns: [
{
input: "Round 1 prompt",
output: "Round 1 response",
toolCalls: [],
tokens: { input: 10, output: 8 },
latencyMs: 100,
},
{
input: "Round 2 prompt",
output: "Round 2 response",
toolCalls: [],
tokens: { input: 12, output: 6 },
latencyMs: 120,
},
],
totalTokens: 36,
durationMs: 220,
};
const sessionHash = await writeReactSession(store, typeHashes, {
agentHash,
role: "multi",
trace,
});
const session = store.get(sessionHash)!.payload as ReactSessionPayload;
expect(session.turns.length).toBe(2);
expect(session.totalTokens).toBe(36);
expect(session.durationMs).toBe(220);
// Turns must be distinct nodes
expect(session.turns[0]).not.toBe(session.turns[1]);
const turn0 = store.get(session.turns[0]!)!.payload as ReactTurnPayload;
expect((store.get(turn0.input)!.payload as ContentPayload).text).toBe("Round 1 prompt");
expect(turn0.tokens).toEqual({ input: 10, output: 8 });
const turn1 = store.get(session.turns[1]!)!.payload as ReactTurnPayload;
expect((store.get(turn1.input)!.payload as ContentPayload).text).toBe("Round 2 prompt");
expect(turn1.tokens).toEqual({ input: 12, output: 6 });
});
});
describe("token and duration values", () => {
test("token counts and latency are preserved exactly", async () => {
const { store, typeHashes } = await setupStore();
const agentHash = await makeFakeAgent(store, typeHashes);
const trace: ReactTrace = {
turns: [
{
input: "p",
output: "r",
toolCalls: [],
tokens: { input: 9999, output: 1234 },
latencyMs: 5678,
},
],
totalTokens: 11233,
durationMs: 5678,
};
const sessionHash = await writeReactSession(store, typeHashes, {
agentHash,
role: "counter",
trace,
});
const session = store.get(sessionHash)!.payload as ReactSessionPayload;
expect(session.totalTokens).toBe(11233);
expect(session.durationMs).toBe(5678);
const turn = store.get(session.turns[0]!)!.payload as ReactTurnPayload;
expect(turn.tokens.input).toBe(9999);
expect(turn.tokens.output).toBe(1234);
expect(turn.latencyMs).toBe(5678);
});
});
});
describe("writeReactSession + executeJsonCasThread integration", () => {
test("engine stores real react session when agent provides react trace", async () => {
const { store, typeHashes } = await setupStore();
const { registerWorkflow } = await import("@uncaged/workflow-json-def");
const { executeJsonCasThread } = await import("../src/engine/json-cas-engine.js");
type JsonCasAgentFn = import("../src/engine/json-cas-types.js").JsonCasAgentFn;
const workflowHash = await registerWorkflow(store, typeHashes, {
name: "react-test",
description: "Tests react instrumentation",
roles: {
solver: {
description: "Solves",
systemPrompt: "Solve it.",
extractPrompt: "Extract.",
schema: {
type: "object",
required: ["answer"],
properties: { answer: { type: "string" } },
},
},
},
moderator: [
{ from: "__start__", to: "solver", when: null },
{ from: "solver", to: "__end__", when: null },
],
});
const agentFn: JsonCasAgentFn = async () => ({
text: "The answer is 42",
meta: { answer: "42" },
react: {
turns: [
{
input: "Solve it. What is the answer?",
output: "The answer is 42",
toolCalls: [],
tokens: { input: 15, output: 8 },
latencyMs: 300,
},
],
totalTokens: 23,
durationMs: 300,
},
});
const result = await executeJsonCasThread({
workflowHash,
input: "What is the answer?",
moderatorRules: [
{ from: "__start__", to: "solver", when: null },
{ from: "solver", to: "__end__", when: null },
],
io: { threadId: "REACT_INTEG", store, typeHashes },
options: { depth: 0, parentThread: null, signal: new AbortController().signal, agents: {} },
agentFn,
logger: () => {},
workflow: null,
});
const endPayload = store.get(result.rootHash)!
.payload as import("@uncaged/json-cas-workflow").ThreadEndPayload;
const stepPayload = store.get(endPayload.lastStep)!
.payload as import("@uncaged/json-cas-workflow").ThreadStepPayload;
const session = store.get(stepPayload.react)!.payload as ReactSessionPayload;
expect(session.turns.length).toBe(1);
expect(session.totalTokens).toBe(23);
expect(session.durationMs).toBe(300);
expect(session.role).toBe("solver");
const turn = store.get(session.turns[0]!)!.payload as ReactTurnPayload;
expect(turn.tokens).toEqual({ input: 15, output: 8 });
expect(turn.latencyMs).toBe(300);
expect((store.get(turn.input)!.payload as ContentPayload).text).toBe(
"Solve it. What is the answer?",
);
});
test("engine falls back to empty react-session when react is null", async () => {
const { store, typeHashes } = await setupStore();
const { registerWorkflow } = await import("@uncaged/workflow-json-def");
const { executeJsonCasThread } = await import("../src/engine/json-cas-engine.js");
type JsonCasAgentFn = import("../src/engine/json-cas-types.js").JsonCasAgentFn;
const workflowHash = await registerWorkflow(store, typeHashes, {
name: "null-react-test",
description: "Tests null react fallback",
roles: {
worker: {
description: "Works",
systemPrompt: "Work.",
extractPrompt: "Extract.",
schema: {
type: "object",
required: ["result"],
properties: { result: { type: "string" } },
},
},
},
moderator: [
{ from: "__start__", to: "worker", when: null },
{ from: "worker", to: "__end__", when: null },
],
});
const agentFn: JsonCasAgentFn = async () => ({
text: "done",
meta: { result: "done" },
react: null,
});
const result = await executeJsonCasThread({
workflowHash,
input: "do it",
moderatorRules: [
{ from: "__start__", to: "worker", when: null },
{ from: "worker", to: "__end__", when: null },
],
io: { threadId: "NULL_REACT", store, typeHashes },
options: { depth: 0, parentThread: null, signal: new AbortController().signal, agents: {} },
agentFn,
logger: () => {},
workflow: null,
});
const endPayload = store.get(result.rootHash)!
.payload as import("@uncaged/json-cas-workflow").ThreadEndPayload;
const stepPayload = store.get(endPayload.lastStep)!
.payload as import("@uncaged/json-cas-workflow").ThreadStepPayload;
const session = store.get(stepPayload.react)!.payload as ReactSessionPayload;
expect(session.turns).toEqual([]);
expect(session.totalTokens).toBe(0);
expect(session.durationMs).toBe(0);
expect(session.role).toBe("worker");
});
});
+3
View File
@@ -24,6 +24,9 @@
"@uncaged/workflow-cas": "workspace:^",
"@uncaged/workflow-reactor": "workspace:^",
"@uncaged/workflow-register": "workspace:^",
"@uncaged/json-cas": "file:../../../json-cas/packages/json-cas",
"@uncaged/json-cas-workflow": "file:../../../json-cas/packages/json-cas-workflow",
"@uncaged/workflow-json-def": "workspace:^",
"yaml": "^2.7.1"
},
"peerDependencies": {
@@ -7,6 +7,27 @@ export {
walkStateFramesNewestFirst,
} from "./fork-thread.js";
export { garbageCollectCas } from "./gc.js";
export {
buildJsonCasThreadContext,
buildJsonCasThreadSnapshot,
readContentText,
} from "./json-cas-context.js";
export { executeJsonCasThread } from "./json-cas-engine.js";
export { writeReactSession } from "./json-cas-react-recorder.js";
export type {
AgentBindings,
JsonCasAgentFn,
JsonCasAgentResult,
JsonCasEngineIo,
JsonCasEngineOptions,
JsonCasStartSnapshot,
JsonCasStepSnapshot,
JsonCasThreadPauseGate,
JsonCasThreadSnapshot,
ReactToolCallTrace,
ReactTrace,
ReactTurnTrace,
} from "./json-cas-types.js";
export { createThreadPauseGate } from "./thread-pause-gate.js";
export type { ThreadHistoryEntry, ThreadIndex, ThreadIndexEntry } from "./threads-index.js";
export {
@@ -0,0 +1,130 @@
import type { Hash, Store } from "@uncaged/json-cas";
import type {
ContentPayload,
ThreadStartPayload,
ThreadStepPayload,
WorkflowSchemaHashes,
} from "@uncaged/json-cas-workflow";
import type { ThreadContext } from "@uncaged/workflow-protocol";
import { START } from "@uncaged/workflow-protocol";
import type { JsonCasStepSnapshot, JsonCasThreadSnapshot } from "./json-cas-types.js";
// ── Snapshot builder (lightweight, for agent & moderator) ─────────────
/**
* Walk the thread-step chain backwards via `previous` refs, then reverse
* to get chronological order. Returns a {@link JsonCasThreadSnapshot}.
*/
export function buildJsonCasThreadSnapshot(
store: Store,
_typeHashes: WorkflowSchemaHashes,
startHash: Hash,
headStepHash: Hash | null,
threadId: string,
): JsonCasThreadSnapshot {
const startNode = store.get(startHash);
if (startNode === null) {
throw new Error(`buildJsonCasThreadSnapshot: missing thread-start node at ${startHash}`);
}
const startPayload = startNode.payload as ThreadStartPayload;
const steps: JsonCasStepSnapshot[] = [];
let cursor: Hash | null = headStepHash;
while (cursor !== null) {
const stepNode = store.get(cursor);
if (stepNode === null) {
throw new Error(`buildJsonCasThreadSnapshot: missing thread-step node at ${cursor}`);
}
const stepPayload = stepNode.payload as ThreadStepPayload;
steps.push({
role: stepPayload.role,
meta: stepPayload.meta,
contentHash: stepPayload.content,
});
cursor = stepPayload.previous;
}
steps.reverse();
return {
threadId,
start: {
input: startPayload.input,
depth: startPayload.depth,
workflowHash: startPayload.workflow,
},
steps,
};
}
// ── ThreadContext builder (protocol-compatible) ───────────────────────
/**
* Build a full {@link ThreadContext} from a json-cas thread chain.
* Reads the thread-start node, walks thread-step backwards, and resolves
* content text from each step's content node.
*
* `bundleHash` is set from the workflow ref in the thread-start payload.
* `threadId` is set to `""` — callers should overwrite when known.
*/
export function buildJsonCasThreadContext(
store: Store,
_typeHashes: WorkflowSchemaHashes,
startHash: Hash,
headStepHash: Hash | null,
): ThreadContext {
const startNode = store.get(startHash);
if (startNode === null) {
throw new Error(`buildJsonCasThreadContext: missing thread-start node at ${startHash}`);
}
const startPayload = startNode.payload as ThreadStartPayload;
const rawSteps: ThreadStepPayload[] = [];
let cursor: Hash | null = headStepHash;
while (cursor !== null) {
const stepNode = store.get(cursor);
if (stepNode === null) {
throw new Error(`buildJsonCasThreadContext: missing thread-step node at ${cursor}`);
}
const payload = stepNode.payload as ThreadStepPayload;
rawSteps.push(payload);
cursor = payload.previous;
}
rawSteps.reverse();
const steps = rawSteps.map((sp) => ({
role: sp.role,
meta: sp.meta,
contentHash: sp.content,
refs: [] as string[],
timestamp: 0,
}));
return {
threadId: "",
depth: startPayload.depth,
bundleHash: startPayload.workflow,
start: {
role: START,
content: startPayload.input,
meta: {},
timestamp: 0,
parentState: startPayload.parentThread,
},
steps,
};
}
/**
* Read the text payload from a content node.
*/
export function readContentText(store: Store, contentHash: Hash): string | null {
const node = store.get(contentHash);
if (node === null) {
return null;
}
const payload = node.payload as ContentPayload;
return payload.text;
}
@@ -0,0 +1,326 @@
import type { Hash, Store } from "@uncaged/json-cas";
import type {
ContentPayload,
ThreadEndPayload,
ThreadStartPayload,
ThreadStepPayload,
WorkflowSchemaHashes,
} from "@uncaged/json-cas-workflow";
import type { HydratedWorkflow } from "@uncaged/workflow-json-def";
import type { ModeratorRule, WorkflowResult } from "@uncaged/workflow-protocol";
import { END, evaluateModerator, START } from "@uncaged/workflow-protocol";
import type { LogFn } from "@uncaged/workflow-util";
import { writeReactSession } from "./json-cas-react-recorder.js";
import type {
AgentBindings,
JsonCasAgentFn,
JsonCasEngineIo,
JsonCasEngineOptions,
JsonCasStepSnapshot,
JsonCasThreadSnapshot,
} from "./json-cas-types.js";
// ── Helpers: CAS node writers ─────────────────────────────────────────
async function writeContent(
store: Store,
typeHashes: WorkflowSchemaHashes,
text: string,
): Promise<Hash> {
const payload: ContentPayload = { text };
return store.put(typeHashes.content, payload);
}
async function writeEmptyReactSession(
store: Store,
typeHashes: WorkflowSchemaHashes,
role: string,
agentHash: Hash,
): Promise<Hash> {
return store.put(typeHashes.reactSession, {
agent: agentHash,
role,
turns: [],
totalTokens: 0,
durationMs: 0,
});
}
async function writeThreadStart(
store: Store,
typeHashes: WorkflowSchemaHashes,
params: {
workflowHash: Hash;
input: string;
depth: number;
parentThread: Hash | null;
agents: AgentBindings;
},
): Promise<Hash> {
const payload: ThreadStartPayload = {
workflow: params.workflowHash,
input: params.input,
depth: params.depth,
parentThread: params.parentThread,
agents: params.agents,
};
return store.put(typeHashes.threadStart, payload);
}
async function writeThreadStep(
store: Store,
typeHashes: WorkflowSchemaHashes,
params: {
role: string;
meta: Record<string, unknown>;
contentHash: Hash;
reactHash: Hash;
startHash: Hash;
previousHash: Hash | null;
},
): Promise<Hash> {
const payload: ThreadStepPayload = {
role: params.role,
meta: params.meta,
content: params.contentHash,
react: params.reactHash,
start: params.startHash,
previous: params.previousHash,
};
return store.put(typeHashes.threadStep, payload);
}
async function writeThreadEnd(
store: Store,
typeHashes: WorkflowSchemaHashes,
params: {
returnCode: number;
summary: string;
startHash: Hash;
lastStepHash: Hash;
},
): Promise<Hash> {
const payload: ThreadEndPayload = {
returnCode: params.returnCode,
summary: params.summary,
start: params.startHash,
lastStep: params.lastStepHash,
};
return store.put(typeHashes.threadEnd, payload);
}
// ── Placeholder agent ─────────────────────────────────────────────────
async function ensurePlaceholderAgent(
store: Store,
typeHashes: WorkflowSchemaHashes,
): Promise<Hash> {
return store.put(typeHashes.agent, {
package: "placeholder",
version: "0.0.0",
config: {},
});
}
// ── JSONata moderator adapter ─────────────────────────────────────────
function snapshotToModeratorContext(
snapshot: JsonCasThreadSnapshot,
): Parameters<typeof evaluateModerator>[1] {
return {
threadId: snapshot.threadId,
depth: snapshot.start.depth,
bundleHash: snapshot.start.workflowHash,
start: {
role: START,
content: snapshot.start.input,
meta: {},
timestamp: 0,
parentState: null,
},
steps: snapshot.steps.map((s) => ({
role: s.role,
meta: s.meta,
contentHash: s.contentHash,
refs: [],
timestamp: 0,
})),
};
}
// ── Main engine ───────────────────────────────────────────────────────
/**
* Execute a workflow thread using json-cas as the storage layer.
*
* Drives the moderator→agent loop:
* 1. Writes a thread-start node.
* 2. On each round: evaluates the moderator, invokes the agent, writes
* content + thread-step nodes (react is a placeholder for now).
* 3. On END: writes a thread-end node and returns the result.
*
* The `agentFn` callback is invoked for each role step. It receives the
* role name, system prompt, and current thread snapshot, and returns the
* agent's text output plus structured meta.
*/
export async function executeJsonCasThread(params: {
workflowHash: Hash;
input: string;
moderatorRules: readonly ModeratorRule[];
io: JsonCasEngineIo;
options: JsonCasEngineOptions;
agentFn: JsonCasAgentFn;
logger: LogFn;
/** Hydrated workflow for role system prompts. Null disables prompt forwarding. */
workflow: HydratedWorkflow | null;
}): Promise<WorkflowResult> {
const { io, options, agentFn, logger, moderatorRules, workflow } = params;
const { store, typeHashes, threadId } = io;
const placeholderAgentHash = await ensurePlaceholderAgent(store, typeHashes);
const startHash = await writeThreadStart(store, typeHashes, {
workflowHash: params.workflowHash,
input: params.input,
depth: options.depth,
parentThread: options.parentThread,
agents: options.agents,
});
logger("X3RK7QWN", `json-cas thread ${threadId} started`);
let previousStepHash: Hash | null = null;
let headStepHash: Hash | null = null;
const stepSnapshots: JsonCasStepSnapshot[] = [];
while (true) {
if (options.signal.aborted) {
return abortThread(store, typeHashes, startHash, headStepHash, logger, threadId);
}
const snapshot: JsonCasThreadSnapshot = {
threadId,
start: {
input: params.input,
depth: options.depth,
workflowHash: params.workflowHash,
},
steps: stepSnapshots,
};
const modCtx = snapshotToModeratorContext(snapshot);
const nextRole = await evaluateModerator(moderatorRules, modCtx);
if (nextRole === END) {
logger("Y5TN8RVK", `json-cas thread ${threadId} moderator returned END`);
if (headStepHash === null) {
const dummyContentHash = await writeContent(store, typeHashes, "no-op");
const dummyReactHash = await writeEmptyReactSession(
store,
typeHashes,
END,
placeholderAgentHash,
);
headStepHash = await writeThreadStep(store, typeHashes, {
role: END,
meta: {},
contentHash: dummyContentHash,
reactHash: dummyReactHash,
startHash,
previousHash: null,
});
}
const endHash = await writeThreadEnd(store, typeHashes, {
returnCode: 0,
summary: "completed: moderator returned END",
startHash,
lastStepHash: headStepHash,
});
return { returnCode: 0, summary: "completed: moderator returned END", rootHash: endHash };
}
const roleSystemPrompt =
workflow !== null && workflow.roles[nextRole] !== undefined
? workflow.roles[nextRole].systemPrompt
: "";
const agentResult = await agentFn(nextRole, roleSystemPrompt, snapshot);
const contentHash = await writeContent(store, typeHashes, agentResult.text);
const agentHash = options.agents[nextRole] ?? placeholderAgentHash;
const reactHash =
agentResult.react !== null
? await writeReactSession(store, typeHashes, {
agentHash,
role: nextRole,
trace: agentResult.react,
})
: await writeEmptyReactSession(store, typeHashes, nextRole, agentHash);
const stepHash = await writeThreadStep(store, typeHashes, {
role: nextRole,
meta: agentResult.meta,
contentHash,
reactHash,
startHash,
previousHash: previousStepHash,
});
previousStepHash = stepHash;
headStepHash = stepHash;
stepSnapshots.push({
role: nextRole,
meta: agentResult.meta,
contentHash,
});
logger("Z7WP4NHK", `json-cas thread ${threadId} wrote role ${nextRole}`);
}
}
async function abortThread(
store: Store,
typeHashes: WorkflowSchemaHashes,
startHash: Hash,
headStepHash: Hash | null,
logger: LogFn,
threadId: string,
): Promise<WorkflowResult> {
logger("A8QK3VNR", `json-cas thread ${threadId} aborted`);
const placeholderAgentHash = await ensurePlaceholderAgent(store, typeHashes);
let lastStep = headStepHash;
if (lastStep === null) {
const dummyContentHash = await writeContent(store, typeHashes, "thread aborted");
const dummyReactHash = await writeEmptyReactSession(
store,
typeHashes,
END,
placeholderAgentHash,
);
lastStep = await writeThreadStep(store, typeHashes, {
role: END,
meta: {},
contentHash: dummyContentHash,
reactHash: dummyReactHash,
startHash,
previousHash: null,
});
}
const endHash = await writeThreadEnd(store, typeHashes, {
returnCode: 130,
summary: "thread aborted",
startHash,
lastStepHash: lastStep,
});
return { returnCode: 130, summary: "thread aborted", rootHash: endHash };
}
@@ -0,0 +1,92 @@
import type { Hash, Store } from "@uncaged/json-cas";
import type {
ContentPayload,
ReactSessionPayload,
ReactToolCallPayload,
ReactTurnPayload,
WorkflowSchemaHashes,
} from "@uncaged/json-cas-workflow";
import type { ReactToolCallTrace, ReactTrace, ReactTurnTrace } from "./json-cas-types.js";
// ── Node writers ──────────────────────────────────────────────────────
async function writeContent(
store: Store,
typeHashes: WorkflowSchemaHashes,
text: string,
): Promise<Hash> {
const payload: ContentPayload = { text };
return store.put(typeHashes.content, payload);
}
async function writeToolCall(
store: Store,
typeHashes: WorkflowSchemaHashes,
toolCall: ReactToolCallTrace,
): Promise<Hash> {
const [argsHash, resultHash] = await Promise.all([
writeContent(store, typeHashes, toolCall.arguments),
writeContent(store, typeHashes, toolCall.result),
]);
const payload: ReactToolCallPayload = {
name: toolCall.name,
arguments: argsHash,
result: resultHash,
durationMs: toolCall.durationMs,
};
return store.put(typeHashes.reactToolCall, payload);
}
async function writeTurn(
store: Store,
typeHashes: WorkflowSchemaHashes,
turn: ReactTurnTrace,
): Promise<Hash> {
const [inputHash, outputHash, toolCallHashes] = await Promise.all([
writeContent(store, typeHashes, turn.input),
writeContent(store, typeHashes, turn.output),
Promise.all(turn.toolCalls.map((tc) => writeToolCall(store, typeHashes, tc))),
]);
const payload: ReactTurnPayload = {
input: inputHash,
output: outputHash,
toolCalls: toolCallHashes,
tokens: turn.tokens,
latencyMs: turn.latencyMs,
};
return store.put(typeHashes.reactTurn, payload);
}
// ── Public API ────────────────────────────────────────────────────────
/**
* Serialise a {@link ReactTrace} captured during an agent run into CAS nodes:
*
* content (args/result) → react-tool-call
* content (input/output) + react-tool-calls → react-turn
* react-turns → react-session
*
* Returns the hash of the written react-session node.
*/
export async function writeReactSession(
store: Store,
typeHashes: WorkflowSchemaHashes,
params: {
agentHash: Hash;
role: string;
trace: ReactTrace;
},
): Promise<Hash> {
const turnHashes = await Promise.all(
params.trace.turns.map((turn) => writeTurn(store, typeHashes, turn)),
);
const payload: ReactSessionPayload = {
agent: params.agentHash,
role: params.role,
turns: turnHashes,
totalTokens: params.trace.totalTokens,
durationMs: params.trace.durationMs,
};
return store.put(typeHashes.reactSession, payload);
}
@@ -0,0 +1,110 @@
import type { Hash, Store } from "@uncaged/json-cas";
import type { WorkflowSchemaHashes } from "@uncaged/json-cas-workflow";
import type { Result } from "@uncaged/workflow-util";
// ── Engine IO ─────────────────────────────────────────────────────────
export type JsonCasEngineIo = {
threadId: string;
store: Store;
typeHashes: WorkflowSchemaHashes;
};
// ── Agent binding ─────────────────────────────────────────────────────
/**
* Maps each role name to a CAS hash referencing an agent node.
* Phase 4 uses a simple role→hash mapping; full agent resolution comes later.
*/
export type AgentBindings = Record<string, Hash>;
// ── Engine options ────────────────────────────────────────────────────
export type JsonCasEngineOptions = {
depth: number;
parentThread: Hash | null;
signal: AbortSignal;
agents: AgentBindings;
};
// ── React trace (raw data before CAS serialisation) ───────────────────
export type ReactToolCallTrace = {
name: string;
/** JSON-serialised arguments */
arguments: string;
/** JSON-serialised result */
result: string;
durationMs: number;
};
export type ReactTurnTrace = {
/** Full prompt text sent to the LLM */
input: string;
/** Raw assistant response text */
output: string;
toolCalls: ReactToolCallTrace[];
tokens: { input: number; output: number };
latencyMs: number;
};
export type ReactTrace = {
turns: ReactTurnTrace[];
totalTokens: number;
durationMs: number;
};
// ── Agent function result ─────────────────────────────────────────────
export type JsonCasAgentResult = {
text: string;
meta: Record<string, unknown>;
/**
* React trace captured during the agent run.
* Null when the agent has no trace to record (e.g. a mock or passthrough).
*/
react: ReactTrace | null;
};
// ── Agent function (mock-friendly) ────────────────────────────────────
/**
* Invoked for each role step. Returns the agent's raw text output,
* structured meta, and an optional react trace. The engine stores the
* text in a content node and the trace in react-* CAS nodes.
*/
export type JsonCasAgentFn = (
role: string,
systemPrompt: string,
context: JsonCasThreadSnapshot,
) => Promise<JsonCasAgentResult>;
// ── Thread snapshot (read-only view for agents & moderator) ───────────
export type JsonCasStartSnapshot = {
input: string;
depth: number;
workflowHash: Hash;
};
export type JsonCasStepSnapshot = {
role: string;
meta: Record<string, unknown>;
contentHash: Hash;
};
export type JsonCasThreadSnapshot = {
threadId: string;
start: JsonCasStartSnapshot;
steps: readonly JsonCasStepSnapshot[];
};
// ── Thread pause gate (re-use from existing types) ────────────────────
export type JsonCasThreadPauseGate = {
awaitAfterYield: () => Promise<void>;
pause: () => Result<void, string>;
resume: () => Result<void, string>;
isPaused: () => boolean;
};
+12
View File
@@ -4,6 +4,18 @@ export {
walkStateFramesNewestFirst,
} from "./engine/fork-thread.js";
export { garbageCollectCas } from "./engine/gc.js";
export { buildJsonCasThreadContext, buildJsonCasThreadSnapshot, readContentText } from "./engine/json-cas-context.js";
export { executeJsonCasThread } from "./engine/json-cas-engine.js";
export type {
AgentBindings,
JsonCasAgentFn,
JsonCasEngineIo,
JsonCasEngineOptions,
JsonCasStartSnapshot,
JsonCasStepSnapshot,
JsonCasThreadPauseGate,
JsonCasThreadSnapshot,
} from "./engine/json-cas-types.js";
export type {
ThreadHistoryEntry,
ThreadIndex,
+2 -1
View File
@@ -11,6 +11,7 @@
{ "path": "../workflow-util" },
{ "path": "../workflow-cas" },
{ "path": "../workflow-reactor" },
{ "path": "../workflow-register" }
{ "path": "../workflow-register" },
{ "path": "../workflow-json-def" }
]
}
@@ -0,0 +1,238 @@
import { describe, expect, test } from "bun:test";
import type { CasNode } from "@uncaged/json-cas";
import { createMemoryStore, refs, validate } from "@uncaged/json-cas";
import type { ThreadStartPayload } from "@uncaged/json-cas-workflow";
import { registerWorkflowSchemas } from "@uncaged/json-cas-workflow";
import { putAgentNode } from "../src/index.js";
// ── Step 6: putAgentNode — CAS agent instance nodes ──────────────────────────
describe("Step 6: putAgentNode", () => {
test("returns a 13-char Crockford Base32 hash", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await putAgentNode(
store,
typeHashes,
"@uncaged/workflow-agent-llm",
"0.5.0-alpha.4",
{ baseUrl: "https://api.example.com", apiKey: "sk-test", model: "gpt-4o" },
);
expect(hash).toHaveLength(13);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("stored agent node is present in the store", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await putAgentNode(
store,
typeHashes,
"@uncaged/workflow-agent-cursor",
"0.5.0-alpha.4",
{
command: "/usr/bin/cursor-agent",
model: null,
timeout: 0,
workspace: null,
},
);
expect(store.get(hash)).not.toBeNull();
});
test("agent node payload contains package, version, and config", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const config = { command: "/usr/bin/hermes", model: "claude-3-5-sonnet", timeout: null };
const hash = await putAgentNode(
store,
typeHashes,
"@uncaged/workflow-agent-hermes",
"0.5.0-alpha.4",
config,
);
const node = store.get(hash) as CasNode;
const payload = node.payload as Record<string, unknown>;
expect(payload.package).toBe("@uncaged/workflow-agent-hermes");
expect(payload.version).toBe("0.5.0-alpha.4");
expect(payload.config).toEqual(config);
});
test("idempotent: same package + version + config returns the same hash", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const config = { baseUrl: "https://api.example.com", apiKey: "sk-test", model: "gpt-4o" };
const hash1 = await putAgentNode(
store,
typeHashes,
"@uncaged/workflow-agent-llm",
"0.5.0-alpha.4",
config,
);
const hash2 = await putAgentNode(
store,
typeHashes,
"@uncaged/workflow-agent-llm",
"0.5.0-alpha.4",
config,
);
expect(hash1).toBe(hash2);
});
test("different configs produce different hashes", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash1 = await putAgentNode(
store,
typeHashes,
"@uncaged/workflow-agent-llm",
"0.5.0-alpha.4",
{
baseUrl: "https://api.example.com",
apiKey: "sk-test",
model: "gpt-4o",
},
);
const hash2 = await putAgentNode(
store,
typeHashes,
"@uncaged/workflow-agent-llm",
"0.5.0-alpha.4",
{
baseUrl: "https://api.example.com",
apiKey: "sk-test",
model: "gpt-4o-mini",
},
);
expect(hash1).not.toBe(hash2);
});
test("agent node passes validation against the agent schema", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await putAgentNode(
store,
typeHashes,
"@uncaged/workflow-agent-react",
"0.5.0-alpha.4",
{
maxRounds: 10,
},
);
const node = store.get(hash) as CasNode;
expect(validate(store, node)).toBe(true);
});
test("agent node with empty config is valid", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await putAgentNode(store, typeHashes, "placeholder", "0.0.0", {});
const node = store.get(hash) as CasNode;
expect(validate(store, node)).toBe(true);
});
});
// ── Step 6: refs from thread-start includes agent refs ────────────────────────
describe("Step 6: refs() from thread-start extracts agent refs", () => {
test("thread-start with agents: refs() returns the agent hashes", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const agentHash1 = await putAgentNode(
store,
typeHashes,
"@uncaged/workflow-agent-llm",
"0.5.0-alpha.4",
{ baseUrl: "https://api.example.com", apiKey: "sk-1", model: "gpt-4o" },
);
const agentHash2 = await putAgentNode(
store,
typeHashes,
"@uncaged/workflow-agent-cursor",
"0.5.0-alpha.4",
{ command: "/usr/bin/cursor-agent", model: null, timeout: 0, workspace: null },
);
const fakeWorkflowHash = "FAKEWF0000001";
const startHash = await store.put(typeHashes.threadStart, {
workflow: fakeWorkflowHash,
input: "test",
depth: 0,
parentThread: null,
agents: { planner: agentHash1, coder: agentHash2 },
} satisfies ThreadStartPayload);
const startNode = store.get(startHash) as CasNode;
const startRefs = refs(store, startNode);
expect(startRefs).toContain(agentHash1);
expect(startRefs).toContain(agentHash2);
});
test("thread-start with no agents: refs() returns only the workflow ref", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const fakeWorkflowHash = "FAKEWF0000002";
const startHash = await store.put(typeHashes.threadStart, {
workflow: fakeWorkflowHash,
input: "empty agents",
depth: 0,
parentThread: null,
agents: {},
} satisfies ThreadStartPayload);
const startNode = store.get(startHash) as CasNode;
const startRefs = refs(store, startNode);
expect(startRefs).toContain(fakeWorkflowHash);
expect(startRefs).toHaveLength(1);
});
test("thread-start with 3 agents: refs() count includes workflow + 3 agents", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const makeAgent = (model: string) =>
putAgentNode(store, typeHashes, "@uncaged/workflow-agent-llm", "0.5.0-alpha.4", {
baseUrl: "https://api.example.com",
apiKey: "sk-x",
model,
});
const [a1, a2, a3] = await Promise.all([makeAgent("m1"), makeAgent("m2"), makeAgent("m3")]);
const fakeWorkflowHash = "FAKEWF0000003";
const startHash = await store.put(typeHashes.threadStart, {
workflow: fakeWorkflowHash,
input: "multi-agent",
depth: 0,
parentThread: null,
agents: { r1: a1, r2: a2, r3: a3 },
} satisfies ThreadStartPayload);
const startNode = store.get(startHash) as CasNode;
const startRefs = refs(store, startNode);
// 1 workflow ref + 3 agent refs = 4
expect(startRefs).toHaveLength(4);
expect(startRefs).toContain(fakeWorkflowHash);
expect(startRefs).toContain(a1);
expect(startRefs).toContain(a2);
expect(startRefs).toContain(a3);
});
});
+19
View File
@@ -0,0 +1,19 @@
import type { Hash, Store } from "@uncaged/json-cas";
import type { WorkflowSchemaHashes } from "@uncaged/json-cas-workflow";
/**
* Store an agent instance in CAS.
*
* Writes an `agent` node with `{ package, version, config }` and returns
* the content-addressed hash. Idempotent: the same inputs always produce
* the same hash.
*/
export async function putAgentNode(
store: Store,
typeHashes: WorkflowSchemaHashes,
pkg: string,
version: string,
config: Record<string, unknown>,
): Promise<Hash> {
return store.put(typeHashes.agent, { package: pkg, version, config });
}
+1
View File
@@ -1,3 +1,4 @@
export { putAgentNode } from "./agent.js";
export {
DEVELOP_WORKFLOW_DESCRIPTION,
developWorkflow,
+1
View File
@@ -23,6 +23,7 @@ export type {
ModeratorContext,
ModeratorTable,
ModeratorTransition,
PackageDescriptor,
ProviderConfig,
ResolvedModel,
Result,
+20
View File
@@ -130,6 +130,26 @@ export type WorkflowConfig = {
models: Record<string, string>;
};
// ── Package Descriptor ────────────────────────────────────────────────
/**
* Static metadata describing a workflow agent npm package.
* Stored alongside the CAS agent node to document what an agent instance is.
*/
export type PackageDescriptor = {
/** The npm package name, e.g. `@uncaged/workflow-agent-cursor`. */
name: string;
/** Semver version of the package at the time the descriptor was written. */
version: string;
/** Human-readable capability tags, e.g. `["cursor-cli", "workspace-agent"]`. */
capabilities: string[];
/**
* JSON Schema that describes the serializable config stored in the CAS
* agent node's `config` field.
*/
configSchema: Record<string, unknown>;
};
// ── Functions ──────────────────────────────────────────────────────
/** Structured output of the extract phase (RFC v3 content Merkle + artifact refs). */
+1
View File
@@ -10,6 +10,7 @@ export type {
ModeratorCondition,
ModeratorContext,
ModeratorTable,
PackageDescriptor,
Result,
RoleDefinition,
RoleFn,