Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a7f417899 | |||
| d00f9df2dd | |||
| ff959be3ef | |||
| f45563ee31 | |||
| 2b8cd99100 | |||
| 1ca13e02b2 | |||
| 3146832d1b | |||
| 64f929c10d | |||
| 1ec32ae0fd | |||
| f851a087f2 | |||
| 984e6ae56d | |||
| 92f3b36b10 | |||
| a4677f8adb |
@@ -62,16 +62,16 @@ See [docs/architecture.md](docs/architecture.md) for the full design — three-p
|
||||
uwf setup
|
||||
|
||||
# 2. Register a workflow from YAML
|
||||
uwf workflow put examples/solve-issue.yaml
|
||||
uwf workflow add examples/solve-issue.yaml
|
||||
|
||||
# 3. Start a thread (creates head pointer; does not execute)
|
||||
uwf thread start solve-issue -p "Fix the login redirect bug"
|
||||
|
||||
# 4. Execute steps (one at a time, until done)
|
||||
uwf thread step <thread-id>
|
||||
uwf thread exec <thread-id>
|
||||
```
|
||||
|
||||
Use `-c, --count <number>` on `thread step` to run multiple steps in one invocation. Override the agent with `--agent <cmd>`.
|
||||
Use `-c, --count <number>` on `thread exec` to run multiple steps in one invocation. Override the agent with `--agent <cmd>`.
|
||||
|
||||
## CLI Reference
|
||||
|
||||
@@ -79,8 +79,9 @@ Global options: `-V, --version`, `--format <json|yaml>`, `-h, --help`.
|
||||
|
||||
| Group | Commands |
|
||||
|-------|----------|
|
||||
| **thread** | `start`, `step`, `show`, `list`, `kill`, `steps`, `read`, `fork`, `step-details` |
|
||||
| **workflow** | `put`, `show`, `list` |
|
||||
| **thread** | `start`, `exec`, `show`, `list`, `stop`, `cancel`, `read` |
|
||||
| **step** | `list`, `show`, `read`, `fork` |
|
||||
| **workflow** | `add`, `show`, `list` |
|
||||
| **cas** | `get`, `put`, `put-text`, `has`, `refs`, `walk`, `reindex`, `schema list`, `schema get` |
|
||||
| **setup** | Interactive or `--provider`, `--base-url`, `--api-key`, `--model`, `--agent` |
|
||||
| **skill** | `cli` — print markdown reference of all uwf commands |
|
||||
|
||||
@@ -22,6 +22,8 @@ roles:
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
enum: ["_"]
|
||||
thesis:
|
||||
type: string
|
||||
keyPoints:
|
||||
@@ -30,14 +32,9 @@ roles:
|
||||
type: string
|
||||
caveats:
|
||||
type: string
|
||||
required: [thesis, keyPoints]
|
||||
conditions: {}
|
||||
required: [status, thesis, keyPoints]
|
||||
graph:
|
||||
$START:
|
||||
- role: "analyst"
|
||||
condition: null
|
||||
prompt: "Analyze the topic in the task and produce a structured summary with key points."
|
||||
_: { role: "analyst", prompt: "Analyze the topic in the task and produce a structured summary with key points." }
|
||||
analyst:
|
||||
- role: "$END"
|
||||
condition: null
|
||||
prompt: "Analysis complete. Finish the workflow."
|
||||
_: { role: "$END", prompt: "Analysis complete. Finish the workflow." }
|
||||
|
||||
+15
-30
@@ -16,15 +16,16 @@ roles:
|
||||
3. If you find yourself genuinely convinced by the other side, you may concede.
|
||||
output: |
|
||||
Provide your argument in the frontmatter.
|
||||
Set conceded to true ONLY if you are genuinely convinced and wish to stop debating.
|
||||
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
|
||||
Otherwise set status to "continue".
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
enum: ["continue", "conceded"]
|
||||
argument:
|
||||
type: string
|
||||
conceded:
|
||||
type: boolean
|
||||
required: [argument, conceded]
|
||||
required: [status, argument]
|
||||
for:
|
||||
description: "Argues for the proposition"
|
||||
goal: |
|
||||
@@ -40,38 +41,22 @@ roles:
|
||||
3. If you find yourself genuinely convinced by the other side, you may concede.
|
||||
output: |
|
||||
Provide your argument in the frontmatter.
|
||||
Set conceded to true ONLY if you are genuinely convinced and wish to stop debating.
|
||||
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
|
||||
Otherwise set status to "continue".
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
enum: ["continue", "conceded"]
|
||||
argument:
|
||||
type: string
|
||||
conceded:
|
||||
type: boolean
|
||||
required: [argument, conceded]
|
||||
conditions:
|
||||
againstConceded:
|
||||
description: "The against side conceded"
|
||||
expression: "$last('against').conceded = true"
|
||||
forConceded:
|
||||
description: "The for side conceded"
|
||||
expression: "$last('for').conceded = true"
|
||||
required: [status, argument]
|
||||
graph:
|
||||
$START:
|
||||
- role: "against"
|
||||
condition: null
|
||||
prompt: "Present your opening argument against the proposition."
|
||||
_: { role: "against", prompt: "Present your opening argument against the proposition." }
|
||||
against:
|
||||
- role: "$END"
|
||||
condition: "againstConceded"
|
||||
prompt: "The against side conceded. Debate over."
|
||||
- role: "for"
|
||||
condition: null
|
||||
prompt: "Counter the opposing argument. Address their points directly."
|
||||
conceded: { role: "$END", prompt: "The against side conceded. Debate over." }
|
||||
continue: { role: "for", prompt: "Counter the opposing argument: {{{argument}}}" }
|
||||
for:
|
||||
- role: "$END"
|
||||
condition: "forConceded"
|
||||
prompt: "The for side conceded. Debate over."
|
||||
- role: "against"
|
||||
condition: null
|
||||
prompt: "Counter the opposing argument. Address their points directly."
|
||||
conceded: { role: "$END", prompt: "The for side conceded. Debate over." }
|
||||
continue: { role: "against", prompt: "Counter the opposing argument: {{{argument}}}" }
|
||||
|
||||
+14
-24
@@ -27,11 +27,13 @@ roles:
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
enum: ["_"]
|
||||
repoPath:
|
||||
type: string
|
||||
plan:
|
||||
type: string
|
||||
required: [repoPath, plan]
|
||||
required: [status, repoPath, plan]
|
||||
developer:
|
||||
description: "Implements code changes"
|
||||
goal: "You are a developer agent. You implement code changes according to plans."
|
||||
@@ -50,13 +52,15 @@ roles:
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
enum: ["_"]
|
||||
filesChanged:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
required: [filesChanged, summary]
|
||||
required: [status, filesChanged, summary]
|
||||
reviewer:
|
||||
description: "Reviews code changes"
|
||||
goal: "You are a code reviewer. You review implementations for correctness and quality."
|
||||
@@ -71,32 +75,18 @@ roles:
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
approved:
|
||||
type: boolean
|
||||
status:
|
||||
enum: ["approved", "rejected"]
|
||||
comments:
|
||||
type: string
|
||||
required: [approved, comments]
|
||||
conditions:
|
||||
notApproved:
|
||||
description: "Reviewer rejected the implementation"
|
||||
expression: "$last('reviewer').approved = false"
|
||||
required: [status, comments]
|
||||
graph:
|
||||
$START:
|
||||
- role: "planner"
|
||||
condition: null
|
||||
prompt: "Analyze the issue described in the task and produce a detailed implementation plan."
|
||||
_: { role: "planner", prompt: "Analyze the issue described in the task and produce a detailed implementation plan." }
|
||||
planner:
|
||||
- role: "developer"
|
||||
condition: null
|
||||
prompt: "Implement the plan from the planner. Write code, tests, and ensure existing tests pass."
|
||||
_: { role: "developer", prompt: "Implement the plan from the planner. Write code, tests, and ensure existing tests pass." }
|
||||
developer:
|
||||
- role: "reviewer"
|
||||
condition: null
|
||||
prompt: "Review the developer's implementation against the plan for correctness and quality."
|
||||
_: { role: "reviewer", prompt: "Review the developer's implementation against the plan for correctness and quality." }
|
||||
reviewer:
|
||||
- role: "developer"
|
||||
condition: "notApproved"
|
||||
prompt: "The reviewer rejected your implementation. Read their feedback and fix the issues."
|
||||
- role: "$END"
|
||||
condition: null
|
||||
prompt: "The review passed. Complete the workflow."
|
||||
approved: { role: "$END", prompt: "The review passed. Complete the workflow." }
|
||||
rejected: { role: "developer", prompt: "The reviewer rejected your implementation. Read their feedback and fix the issues: {{{comments}}}" }
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"uwf": "bun packages/cli-workflow/src/cli.ts",
|
||||
"build": "bunx tsc --build",
|
||||
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
|
||||
"typecheck": "bunx tsc --build",
|
||||
|
||||
@@ -49,8 +49,10 @@ bun link packages/cli-workflow
|
||||
| `uwf thread start <workflow> -p <prompt>` | Create a thread without executing |
|
||||
| `uwf thread exec <thread-id> [--agent <cmd>] [-c <count>] [--background]` | Execute one or more moderator→agent→extract cycles |
|
||||
| `uwf thread show <thread-id>` | Show thread head pointer |
|
||||
| `uwf thread list [--status <idle\|running\|completed>]` | List threads, optionally filtered by status |
|
||||
| `uwf thread list [--status <status>] [--after <date>] [--before <date>] [--skip <n>] [--take <n>]` | List threads filtered by status (idle, running, completed, active, or comma-separated), time range (ISO or relative like '7d'), with pagination |
|
||||
| `uwf thread read <thread-id> [--quota N] [--before <hash>] [--start]` | Render thread as readable markdown |
|
||||
|
||||
`thread read`, `step list`, and `step show` work on both active and completed threads.
|
||||
| `uwf thread stop <thread-id>` | Stop background execution (keep thread active) |
|
||||
| `uwf thread cancel <thread-id>` | Cancel thread (stop + archive to history) |
|
||||
|
||||
@@ -62,6 +64,9 @@ uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||
uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV -c 3 --agent uwf-builtin
|
||||
uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV --background
|
||||
uwf thread list --status running
|
||||
uwf thread list --status active
|
||||
uwf thread list --status idle,completed
|
||||
uwf thread list --after 7d --take 10
|
||||
uwf thread read 01ARZ3NDEKTSV4RRFFQ69G5FAV --quota 8000
|
||||
uwf thread stop 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||
```
|
||||
@@ -72,6 +77,7 @@ uwf thread stop 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||
|---------|-------------|
|
||||
| `uwf step list <thread-id>` | List all steps in a thread chronologically |
|
||||
| `uwf step show <step-hash>` | Show step metadata and frontmatter |
|
||||
| `uwf step read <step-hash> [--quota <chars>]` | Read a step's turns as human-readable markdown |
|
||||
| `uwf step fork <step-hash>` | Fork a thread from a specific step |
|
||||
|
||||
Examples:
|
||||
@@ -79,6 +85,7 @@ Examples:
|
||||
```bash
|
||||
uwf step list 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||
uwf step show 32GCDE899RRQ3
|
||||
uwf step read 32GCDE899RRQ3 --quota 2000
|
||||
uwf step fork 32GCDE899RRQ3
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { cmdCasPutText } from "../commands/cas.js";
|
||||
|
||||
let storageRoot: string;
|
||||
let uwfPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
storageRoot = join(
|
||||
tmpdir(),
|
||||
`uwf-cas-exit-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
|
||||
// Find the uwf CLI path
|
||||
uwfPath = join(__dirname, "../../src/cli.ts");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
type ExecResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
};
|
||||
|
||||
function execUwf(args: string[]): ExecResult {
|
||||
try {
|
||||
const stdout = execSync(`bun ${uwfPath} ${args.join(" ")}`, {
|
||||
env: { ...process.env, WORKFLOW_STORAGE_ROOT: storageRoot },
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
return { stdout, stderr: "", exitCode: 0 };
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"stdout" in error &&
|
||||
"stderr" in error &&
|
||||
"status" in error
|
||||
) {
|
||||
return {
|
||||
stdout: (error.stdout as Buffer | string).toString(),
|
||||
stderr: (error.stderr as Buffer | string).toString(),
|
||||
exitCode: error.status as number,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
describe("uwf cas has CLI exit codes", () => {
|
||||
test("exits 0 when hash exists", async () => {
|
||||
// Setup: Create a temp storage root, put a text node, capture hash
|
||||
const putResult = await cmdCasPutText(storageRoot, "test content");
|
||||
const hash = putResult.hash;
|
||||
|
||||
// Execute: uwf cas has <hash>
|
||||
const result = execUwf(["cas", "has", hash]);
|
||||
|
||||
// Assert: stdout contains {"exists":true}, exit code === 0
|
||||
expect(result.stdout).toContain('"exists":true');
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("exits 1 when hash does not exist", () => {
|
||||
// Setup: Create a temp storage root (empty CAS store)
|
||||
// Execute: uwf cas has NOSUCHHASH123
|
||||
const result = execUwf(["cas", "has", "NOSUCHHASH123"]);
|
||||
|
||||
// Assert: stdout contains {"exists":false}, exit code === 1
|
||||
expect(result.stdout).toContain('"exists":false');
|
||||
expect(result.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
test("JSON output format unchanged for exists=true", async () => {
|
||||
// Setup: Create store, put node
|
||||
const putResult = await cmdCasPutText(storageRoot, "test");
|
||||
const hash = putResult.hash;
|
||||
|
||||
// Execute: uwf cas has <hash>
|
||||
const result = execUwf(["cas", "has", hash]);
|
||||
|
||||
// Assert: stdout JSON parses correctly to {exists: true}
|
||||
const parsed = JSON.parse(result.stdout.trim());
|
||||
expect(parsed).toEqual({ exists: true });
|
||||
});
|
||||
|
||||
test("JSON output format unchanged for exists=false", () => {
|
||||
// Setup: Create empty store
|
||||
// Execute: uwf cas has INVALID
|
||||
const result = execUwf(["cas", "has", "INVALID"]);
|
||||
|
||||
// Assert: stdout JSON parses correctly to {exists: false}
|
||||
const parsed = JSON.parse(result.stdout.trim());
|
||||
expect(parsed).toEqual({ exists: false });
|
||||
});
|
||||
|
||||
test("YAML output format preserves exit code behavior for exists=true", async () => {
|
||||
// Setup: Create store with node
|
||||
const putResult = await cmdCasPutText(storageRoot, "test");
|
||||
const hash = putResult.hash;
|
||||
|
||||
// Execute: uwf --format yaml cas has <hash>
|
||||
const result = execUwf(["--format", "yaml", "cas", "has", hash]);
|
||||
|
||||
// Assert: exit code === 0, output is YAML format
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("exists:");
|
||||
expect(result.stdout).toContain("true");
|
||||
});
|
||||
|
||||
test("YAML output format preserves exit code behavior for exists=false", () => {
|
||||
// Setup: Create empty store
|
||||
// Execute: uwf --format yaml cas has INVALID
|
||||
const result = execUwf(["--format", "yaml", "cas", "has", "INVALID"]);
|
||||
|
||||
// Assert: exit code === 1, output is YAML format
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stdout).toContain("exists:");
|
||||
expect(result.stdout).toContain("false");
|
||||
});
|
||||
});
|
||||
|
||||
describe("regression: other cas commands unaffected", () => {
|
||||
test("uwf cas get still exits 1 on not-found with error message", () => {
|
||||
// Execute: uwf cas get NOSUCHHASH
|
||||
const result = execUwf(["cas", "get", "NOSUCHHASH"]);
|
||||
|
||||
// Assert: exit code === 1, stderr contains "Node not found"
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain("Node not found");
|
||||
});
|
||||
|
||||
test("uwf cas put-text behavior unchanged", () => {
|
||||
// Execute: uwf cas put-text "hello"
|
||||
const result = execUwf(["cas", "put-text", "hello"]);
|
||||
|
||||
// Assert: exit code === 0, returns hash
|
||||
expect(result.exitCode).toBe(0);
|
||||
const parsed = JSON.parse(result.stdout.trim());
|
||||
expect(parsed).toHaveProperty("hash");
|
||||
expect(typeof parsed.hash).toBe("string");
|
||||
expect(parsed.hash.length).toBe(13); // Crockford Base32 XXH64 hash length
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { cmdCasHas, cmdCasPutText } from "../commands/cas.js";
|
||||
|
||||
let storageRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
storageRoot = join(tmpdir(), `uwf-cas-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("cmdCasHas", () => {
|
||||
test("returns {exists: true} for existing hash", async () => {
|
||||
// Setup: Create a test store, put a node, get its hash
|
||||
const putResult = await cmdCasPutText(storageRoot, "test content");
|
||||
const hash = putResult.hash;
|
||||
|
||||
// Execute: Call cmdCasHas with the valid hash
|
||||
const result = await cmdCasHas(storageRoot, hash);
|
||||
|
||||
// Assert: Result equals {exists: true}
|
||||
expect(result).toEqual({ exists: true });
|
||||
});
|
||||
|
||||
test("returns {exists: false} for non-existent hash", async () => {
|
||||
// Setup: Create an empty test store
|
||||
// (storageRoot already created in beforeEach)
|
||||
|
||||
// Execute: Call cmdCasHas with an invalid hash
|
||||
const result = await cmdCasHas(storageRoot, "INVALIDHASH12");
|
||||
|
||||
// Assert: Result equals {exists: false}
|
||||
expect(result).toEqual({ exists: false });
|
||||
});
|
||||
|
||||
test("does not throw for non-existent hash", async () => {
|
||||
// Setup: Create an empty test store
|
||||
// Execute & Assert: Does not throw, returns {exists: false}
|
||||
await expect(cmdCasHas(storageRoot, "NOSUCHHASH123")).resolves.toEqual({
|
||||
exists: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("handles malformed hash gracefully", async () => {
|
||||
// Setup: Create a test store
|
||||
// Execute: Call cmdCasHas with a too-short hash
|
||||
const result = await cmdCasHas(storageRoot, "xyz");
|
||||
|
||||
// Assert: Returns {exists: false} (store.has() returns false)
|
||||
expect(result).toEqual({ exists: false });
|
||||
});
|
||||
|
||||
test("handles empty hash string", async () => {
|
||||
// Execute: Call cmdCasHas with an empty string
|
||||
const result = await cmdCasHas(storageRoot, "");
|
||||
|
||||
// Assert: Returns {exists: false}
|
||||
expect(result).toEqual({ exists: false });
|
||||
});
|
||||
|
||||
test("handles hash with special characters", async () => {
|
||||
// Execute: Call cmdCasHas with special characters
|
||||
const result = await cmdCasHas(storageRoot, "HASH!@#");
|
||||
|
||||
// Assert: Returns {exists: false}
|
||||
expect(result).toEqual({ exists: false });
|
||||
});
|
||||
});
|
||||
@@ -70,7 +70,6 @@ describe("solve-issue workflow: tea pr create worktree fix", () => {
|
||||
// Basic structure validation
|
||||
expect(workflow.name).toBe("solve-issue");
|
||||
expect(workflow.roles).toBeDefined();
|
||||
expect(workflow.conditions).toBeDefined();
|
||||
expect(workflow.graph).toBeDefined();
|
||||
|
||||
// Verify committer role exists with required fields
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { bootstrap, putSchema } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import type { CasRef } from "@uncaged/workflow-protocol";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { cmdStepRead } from "../commands/step.js";
|
||||
import { registerUwfSchemas } from "../schemas.js";
|
||||
|
||||
// ── schemas used in tests ────────────────────────────────────────────────────
|
||||
|
||||
const TURN_SCHEMA = {
|
||||
title: "hermes-turn",
|
||||
type: "object" as const,
|
||||
required: ["index", "role", "content"],
|
||||
properties: {
|
||||
index: { type: "integer" as const },
|
||||
role: { type: "string" as const },
|
||||
content: { type: "string" as const },
|
||||
toolCalls: {
|
||||
anyOf: [
|
||||
{ type: "array" as const, items: { type: "object" as const } },
|
||||
{ type: "null" as const },
|
||||
],
|
||||
},
|
||||
reasoning: { anyOf: [{ type: "string" as const }, { type: "null" as const }] },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const DETAIL_SCHEMA = {
|
||||
title: "hermes-detail",
|
||||
type: "object" as const,
|
||||
required: ["sessionId", "model", "duration", "turnCount", "turns"],
|
||||
properties: {
|
||||
sessionId: { type: "string" as const },
|
||||
model: { type: "string" as const },
|
||||
duration: { type: "integer" as const },
|
||||
turnCount: { type: "integer" as const },
|
||||
turns: {
|
||||
type: "array" as const,
|
||||
items: { type: "string" as const, format: "cas_ref" },
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function registerDetailSchemas(store: ReturnType<typeof createFsStore>) {
|
||||
await bootstrap(store);
|
||||
const [turn, detail] = await Promise.all([
|
||||
putSchema(store, TURN_SCHEMA),
|
||||
putSchema(store, DETAIL_SCHEMA),
|
||||
]);
|
||||
return { turn, detail };
|
||||
}
|
||||
|
||||
function generateContent(size: number, prefix = "Content"): string {
|
||||
const base = `${prefix} `;
|
||||
const repeat = Math.ceil(size / base.length);
|
||||
return base.repeat(repeat).slice(0, size);
|
||||
}
|
||||
|
||||
// ── fixture ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-step-read-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── step read tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("step read", () => {
|
||||
test("test 1: basic single-step read with 3 turns", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const detailSchemas = await registerDetailSchemas(store);
|
||||
|
||||
const workflowHash = await store.put(schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker",
|
||||
goal: "You are a worker agent.",
|
||||
capabilities: [],
|
||||
procedure: "Do the work.",
|
||||
output: "Summarize the work.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Test task",
|
||||
});
|
||||
|
||||
const outputHash = await store.put(schemas.workflow, {
|
||||
name: "out",
|
||||
description: "",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
// Create 3 turns
|
||||
const turnHashes: CasRef[] = [];
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const content = `Turn ${i} content with some text to make it readable.`;
|
||||
const turnHash = await store.put(detailSchemas.turn, {
|
||||
index: i - 1,
|
||||
role: "assistant",
|
||||
content,
|
||||
toolCalls: null,
|
||||
reasoning: null,
|
||||
});
|
||||
turnHashes.push(turnHash);
|
||||
}
|
||||
|
||||
const detailHash = await store.put(detailSchemas.detail, {
|
||||
sessionId: "session-1",
|
||||
model: "test-model",
|
||||
duration: 1000,
|
||||
turnCount: 3,
|
||||
turns: turnHashes,
|
||||
});
|
||||
|
||||
const stepHash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
|
||||
// Read step with large quota
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 10000);
|
||||
|
||||
// Assert structure
|
||||
expect(markdown).toContain(`# Step ${stepHash}`);
|
||||
expect(markdown).toContain("**Role:** worker");
|
||||
expect(markdown).toContain("**Agent:** uwf-test");
|
||||
expect(markdown).toContain("## Turn 1");
|
||||
expect(markdown).toContain("## Turn 2");
|
||||
expect(markdown).toContain("## Turn 3");
|
||||
expect(markdown).toContain("Turn 1 content with some text to make it readable.");
|
||||
expect(markdown).toContain("Turn 2 content with some text to make it readable.");
|
||||
expect(markdown).toContain("Turn 3 content with some text to make it readable.");
|
||||
});
|
||||
|
||||
test("test 2: quota enforcement - multiple turns", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const detailSchemas = await registerDetailSchemas(store);
|
||||
|
||||
const workflowHash = await store.put(schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker",
|
||||
goal: "You are a worker agent.",
|
||||
capabilities: [],
|
||||
procedure: "Do the work.",
|
||||
output: "Summarize the work.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Test task",
|
||||
});
|
||||
|
||||
const outputHash = await store.put(schemas.workflow, {
|
||||
name: "out",
|
||||
description: "",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
// Create 4 turns of ~300 chars each
|
||||
const turnHashes: CasRef[] = [];
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const content = generateContent(300, `Turn${i}`);
|
||||
const turnHash = await store.put(detailSchemas.turn, {
|
||||
index: i - 1,
|
||||
role: "assistant",
|
||||
content,
|
||||
toolCalls: null,
|
||||
reasoning: null,
|
||||
});
|
||||
turnHashes.push(turnHash);
|
||||
}
|
||||
|
||||
const detailHash = await store.put(detailSchemas.detail, {
|
||||
sessionId: "session-1",
|
||||
model: "test-model",
|
||||
duration: 1000,
|
||||
turnCount: 4,
|
||||
turns: turnHashes,
|
||||
});
|
||||
|
||||
const stepHash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
|
||||
// Read step with limited quota (700 chars)
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 700);
|
||||
|
||||
// Assert only most recent turns fit
|
||||
expect(markdown).toContain(`# Step ${stepHash}`);
|
||||
// Should have skip hint
|
||||
expect(markdown).toContain("Earlier turns omitted");
|
||||
// Should include at least Turn 4 (most recent)
|
||||
expect(markdown).toContain("Turn4");
|
||||
// Total length should respect quota (with tolerance for structural overhead)
|
||||
expect(markdown.length).toBeLessThanOrEqual(900); // 700 quota + 200 buffer tolerance
|
||||
});
|
||||
|
||||
test("test 3: minimal quota edge case - always show at least one turn", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const detailSchemas = await registerDetailSchemas(store);
|
||||
|
||||
const workflowHash = await store.put(schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker",
|
||||
goal: "You are a worker agent.",
|
||||
capabilities: [],
|
||||
procedure: "Do the work.",
|
||||
output: "Summarize the work.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Test task",
|
||||
});
|
||||
|
||||
const outputHash = await store.put(schemas.workflow, {
|
||||
name: "out",
|
||||
description: "",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
// Create 1 turn of 500 chars
|
||||
const content = generateContent(500, "LongTurn");
|
||||
const turnHash = await store.put(detailSchemas.turn, {
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content,
|
||||
toolCalls: null,
|
||||
reasoning: null,
|
||||
});
|
||||
|
||||
const detailHash = await store.put(detailSchemas.detail, {
|
||||
sessionId: "session-1",
|
||||
model: "test-model",
|
||||
duration: 1000,
|
||||
turnCount: 1,
|
||||
turns: [turnHash],
|
||||
});
|
||||
|
||||
const stepHash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
|
||||
// Read step with minimal quota (1 char)
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 1);
|
||||
|
||||
// Assert at least one turn is always shown
|
||||
expect(markdown).toContain("LongTurn");
|
||||
expect(markdown.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test("test 4: step with no detail field", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
|
||||
const workflowHash = await store.put(schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker",
|
||||
goal: "You are a worker agent.",
|
||||
capabilities: [],
|
||||
procedure: "Do the work.",
|
||||
output: "Summarize the work.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Test task",
|
||||
});
|
||||
|
||||
const outputHash = await store.put(schemas.workflow, {
|
||||
name: "out",
|
||||
description: "",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const stepHash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
|
||||
// Read step - should return metadata only (no error)
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
|
||||
|
||||
// Assert metadata is present
|
||||
expect(markdown).toContain(`# Step ${stepHash}`);
|
||||
expect(markdown).toContain("**Role:** worker");
|
||||
expect(markdown).toContain("**Agent:** uwf-test");
|
||||
// Should not have turn sections
|
||||
expect(markdown).not.toContain("## Turn");
|
||||
});
|
||||
|
||||
test("test 5: step with detail but no turns array", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
await registerDetailSchemas(store);
|
||||
|
||||
const workflowHash = await store.put(schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker",
|
||||
goal: "You are a worker agent.",
|
||||
capabilities: [],
|
||||
procedure: "Do the work.",
|
||||
output: "Summarize the work.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Test task",
|
||||
});
|
||||
|
||||
const outputHash = await store.put(schemas.workflow, {
|
||||
name: "out",
|
||||
description: "",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
// Create detail with different schema (no turns)
|
||||
const SIMPLE_DETAIL_SCHEMA = {
|
||||
title: "simple-detail",
|
||||
type: "object" as const,
|
||||
required: ["sessionId"],
|
||||
properties: {
|
||||
sessionId: { type: "string" as const },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
await bootstrap(store);
|
||||
const simpleDetailType = await putSchema(store, SIMPLE_DETAIL_SCHEMA);
|
||||
const detailHash = await store.put(simpleDetailType, {
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
const stepHash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
|
||||
// Read step - should return metadata only (no error)
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
|
||||
|
||||
// Assert metadata is present
|
||||
expect(markdown).toContain(`# Step ${stepHash}`);
|
||||
expect(markdown).toContain("**Role:** worker");
|
||||
// Should not have turn sections
|
||||
expect(markdown).not.toContain("## Turn");
|
||||
});
|
||||
|
||||
test("test 6: turn content with special characters", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const detailSchemas = await registerDetailSchemas(store);
|
||||
|
||||
const workflowHash = await store.put(schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker",
|
||||
goal: "You are a worker agent.",
|
||||
capabilities: [],
|
||||
procedure: "Do the work.",
|
||||
output: "Summarize the work.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Test task",
|
||||
});
|
||||
|
||||
const outputHash = await store.put(schemas.workflow, {
|
||||
name: "out",
|
||||
description: "",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
// Create turn with special markdown characters
|
||||
const content = "This has `backticks`, **bold**, *italic*, and [links](http://example.com)";
|
||||
const turnHash = await store.put(detailSchemas.turn, {
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content,
|
||||
toolCalls: null,
|
||||
reasoning: null,
|
||||
});
|
||||
|
||||
const detailHash = await store.put(detailSchemas.detail, {
|
||||
sessionId: "session-1",
|
||||
model: "test-model",
|
||||
duration: 1000,
|
||||
turnCount: 1,
|
||||
turns: [turnHash],
|
||||
});
|
||||
|
||||
const stepHash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
|
||||
// Read step
|
||||
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
|
||||
|
||||
// Assert content is rendered correctly without corruption
|
||||
expect(markdown).toContain("`backticks`");
|
||||
expect(markdown).toContain("**bold**");
|
||||
expect(markdown).toContain("*italic*");
|
||||
expect(markdown).toContain("[links](http://example.com)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,583 @@
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { bootstrap, putSchema } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { cmdThreadRead } from "../commands/thread.js";
|
||||
import { registerUwfSchemas } from "../schemas.js";
|
||||
import { saveThreadsIndex } from "../store.js";
|
||||
|
||||
// ── schemas used in tests ────────────────────────────────────────────────────
|
||||
|
||||
const TURN_SCHEMA = {
|
||||
title: "hermes-turn",
|
||||
type: "object" as const,
|
||||
required: ["index", "role", "content"],
|
||||
properties: {
|
||||
index: { type: "integer" as const },
|
||||
role: { type: "string" as const },
|
||||
content: { type: "string" as const },
|
||||
toolCalls: {
|
||||
anyOf: [
|
||||
{ type: "array" as const, items: { type: "object" as const } },
|
||||
{ type: "null" as const },
|
||||
],
|
||||
},
|
||||
reasoning: { anyOf: [{ type: "string" as const }, { type: "null" as const }] },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const DETAIL_SCHEMA = {
|
||||
title: "hermes-detail",
|
||||
type: "object" as const,
|
||||
required: ["sessionId", "model", "duration", "turnCount", "turns"],
|
||||
properties: {
|
||||
sessionId: { type: "string" as const },
|
||||
model: { type: "string" as const },
|
||||
duration: { type: "integer" as const },
|
||||
turnCount: { type: "integer" as const },
|
||||
turns: {
|
||||
type: "array" as const,
|
||||
items: { type: "string" as const, format: "cas_ref" },
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function registerDetailSchemas(store: ReturnType<typeof createFsStore>) {
|
||||
await bootstrap(store);
|
||||
const [turn, detail] = await Promise.all([
|
||||
putSchema(store, TURN_SCHEMA),
|
||||
putSchema(store, DETAIL_SCHEMA),
|
||||
]);
|
||||
return { turn, detail };
|
||||
}
|
||||
|
||||
function generateContent(size: number, prefix = "Content"): string {
|
||||
const base = `${prefix} `;
|
||||
const repeat = Math.ceil(size / base.length);
|
||||
return base.repeat(repeat).slice(0, size);
|
||||
}
|
||||
|
||||
// ── fixture ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-quota-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── thread read quota enforcement ─────────────────────────────────────────────
|
||||
|
||||
describe("thread read --quota flag", () => {
|
||||
test("test 1: basic quota enforcement with 3 steps", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const detailSchemas = await registerDetailSchemas(store);
|
||||
|
||||
const workflowHash = await store.put(schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker",
|
||||
goal: "You are a worker agent.",
|
||||
capabilities: [],
|
||||
procedure: "Do the work.",
|
||||
output: "Summarize the work.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Test task",
|
||||
});
|
||||
|
||||
const outputHash = await store.put(schemas.workflow, {
|
||||
name: "out",
|
||||
description: "",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
// Create 3 steps with ~500 chars each
|
||||
const steps: CasRef[] = [];
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const content = generateContent(500, `Step${i}`);
|
||||
const turnHash = await store.put(detailSchemas.turn, {
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content,
|
||||
toolCalls: null,
|
||||
reasoning: null,
|
||||
});
|
||||
const detailHash = await store.put(detailSchemas.detail, {
|
||||
sessionId: `session-${i}`,
|
||||
model: "test-model",
|
||||
duration: 1000,
|
||||
turnCount: 1,
|
||||
turns: [turnHash],
|
||||
});
|
||||
const stepHash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: steps[i - 2] ?? null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
steps.push(stepHash);
|
||||
}
|
||||
|
||||
const threadId = "01HX2Q3R4S5T6V7W8X9YZ0" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: steps[2] as CasRef });
|
||||
|
||||
// Set quota to 800 chars - should only fit most recent steps
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, 800, null, false);
|
||||
|
||||
// Quota must be reasonably enforced (allow ~200 char tolerance for skip hint)
|
||||
expect(markdown.length).toBeLessThanOrEqual(1000);
|
||||
|
||||
// Should contain skip hint since not all steps fit
|
||||
expect(markdown).toMatch(/earlier step/);
|
||||
|
||||
// Most recent step should be included
|
||||
expect(markdown).toMatch(/Step3/);
|
||||
});
|
||||
|
||||
test("test 2: quota check order - verifies bug is fixed", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const detailSchemas = await registerDetailSchemas(store);
|
||||
|
||||
const workflowHash = await store.put(schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker",
|
||||
goal: "You are a worker agent.",
|
||||
capabilities: [],
|
||||
procedure: "Do the work.",
|
||||
output: "Summarize the work.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Test task",
|
||||
});
|
||||
|
||||
const outputHash = await store.put(schemas.workflow, {
|
||||
name: "out",
|
||||
description: "",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
// Create 2 steps: first=300 chars, second=600 chars
|
||||
const step1Content = generateContent(300, "First");
|
||||
const step1TurnHash = await store.put(detailSchemas.turn, {
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: step1Content,
|
||||
toolCalls: null,
|
||||
reasoning: null,
|
||||
});
|
||||
const step1DetailHash = await store.put(detailSchemas.detail, {
|
||||
sessionId: "session-1",
|
||||
model: "test-model",
|
||||
duration: 1000,
|
||||
turnCount: 1,
|
||||
turns: [step1TurnHash],
|
||||
});
|
||||
const step1Hash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: step1DetailHash,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
|
||||
const step2Content = generateContent(600, "Second");
|
||||
const step2TurnHash = await store.put(detailSchemas.turn, {
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: step2Content,
|
||||
toolCalls: null,
|
||||
reasoning: null,
|
||||
});
|
||||
const step2DetailHash = await store.put(detailSchemas.detail, {
|
||||
sessionId: "session-2",
|
||||
model: "test-model",
|
||||
duration: 1000,
|
||||
turnCount: 1,
|
||||
turns: [step2TurnHash],
|
||||
});
|
||||
const step2Hash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: step1Hash,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: step2DetailHash,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
|
||||
const threadId = "01HX2Q3R4S5T6V7W8X9YZ1" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: step2Hash });
|
||||
|
||||
// Set quota to 500 chars
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, 500, null, false);
|
||||
|
||||
// Bug fix verification: output must be limited (allow ~200 char tolerance)
|
||||
expect(markdown.length).toBeLessThanOrEqual(1100);
|
||||
|
||||
// Should contain "Second" (most recent step)
|
||||
expect(markdown).toMatch(/Second/);
|
||||
|
||||
// Should skip first step
|
||||
expect(markdown).toMatch(/earlier step/);
|
||||
|
||||
// Verify improvement: before fix would be ~1264, now should be much closer to 500
|
||||
expect(markdown.length).toBeLessThan(1200);
|
||||
});
|
||||
|
||||
test("test 3: quota with --start section", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const detailSchemas = await registerDetailSchemas(store);
|
||||
|
||||
const workflowHash = await store.put(schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker",
|
||||
goal: "You are a worker agent.",
|
||||
capabilities: [],
|
||||
procedure: "Do the work.",
|
||||
output: "Summarize the work.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Test task with a moderately long prompt to test quota accounting",
|
||||
});
|
||||
|
||||
const outputHash = await store.put(schemas.workflow, {
|
||||
name: "out",
|
||||
description: "",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
// Create 2 steps
|
||||
const steps: CasRef[] = [];
|
||||
for (let i = 1; i <= 2; i++) {
|
||||
const content = generateContent(400, `Step${i}`);
|
||||
const turnHash = await store.put(detailSchemas.turn, {
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content,
|
||||
toolCalls: null,
|
||||
reasoning: null,
|
||||
});
|
||||
const detailHash = await store.put(detailSchemas.detail, {
|
||||
sessionId: `session-${i}`,
|
||||
model: "test-model",
|
||||
duration: 1000,
|
||||
turnCount: 1,
|
||||
turns: [turnHash],
|
||||
});
|
||||
const stepHash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: steps[i - 2] ?? null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
steps.push(stepHash);
|
||||
}
|
||||
|
||||
const threadId = "01HX2Q3R4S5T6V7W8X9YZ2" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: steps[1] as CasRef });
|
||||
|
||||
// Set tight quota with --start flag
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, 600, null, true);
|
||||
|
||||
// Quota must be reasonably enforced (allow ~210 char tolerance for structure)
|
||||
expect(markdown.length).toBeLessThanOrEqual(810);
|
||||
|
||||
// Should contain thread header
|
||||
expect(markdown).toMatch(/# Thread/);
|
||||
expect(markdown).toMatch(/test-wf/);
|
||||
});
|
||||
|
||||
test("test 5a: quota edge case - minimal quota", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const detailSchemas = await registerDetailSchemas(store);
|
||||
|
||||
const workflowHash = await store.put(schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker",
|
||||
goal: "You are a worker agent.",
|
||||
capabilities: [],
|
||||
procedure: "Do the work.",
|
||||
output: "Summarize the work.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Test task",
|
||||
});
|
||||
|
||||
const outputHash = await store.put(schemas.workflow, {
|
||||
name: "out",
|
||||
description: "",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const content = generateContent(500, "Test");
|
||||
const turnHash = await store.put(detailSchemas.turn, {
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content,
|
||||
toolCalls: null,
|
||||
reasoning: null,
|
||||
});
|
||||
const detailHash = await store.put(detailSchemas.detail, {
|
||||
sessionId: "session-1",
|
||||
model: "test-model",
|
||||
duration: 1000,
|
||||
turnCount: 1,
|
||||
turns: [turnHash],
|
||||
});
|
||||
const stepHash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
|
||||
const threadId = "01HX2Q3R4S5T6V7W8X9YZ4" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||
|
||||
// Minimal quota
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, 1, null, false);
|
||||
|
||||
// Should handle gracefully - always shows at least one step
|
||||
expect(markdown.length).toBeGreaterThan(1);
|
||||
expect(markdown).toMatch(/Test/);
|
||||
});
|
||||
|
||||
test("test 5b: quota edge case - very large quota", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const detailSchemas = await registerDetailSchemas(store);
|
||||
|
||||
const workflowHash = await store.put(schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker",
|
||||
goal: "You are a worker agent.",
|
||||
capabilities: [],
|
||||
procedure: "Do the work.",
|
||||
output: "Summarize the work.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Test task",
|
||||
});
|
||||
|
||||
const outputHash = await store.put(schemas.workflow, {
|
||||
name: "out",
|
||||
description: "",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
// Create 3 steps
|
||||
const steps: CasRef[] = [];
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const content = generateContent(300, `Step${i}`);
|
||||
const turnHash = await store.put(detailSchemas.turn, {
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content,
|
||||
toolCalls: null,
|
||||
reasoning: null,
|
||||
});
|
||||
const detailHash = await store.put(detailSchemas.detail, {
|
||||
sessionId: `session-${i}`,
|
||||
model: "test-model",
|
||||
duration: 1000,
|
||||
turnCount: 1,
|
||||
turns: [turnHash],
|
||||
});
|
||||
const stepHash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: steps[i - 2] ?? null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
steps.push(stepHash);
|
||||
}
|
||||
|
||||
const threadId = "01HX2Q3R4S5T6V7W8X9YZ5" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: steps[2] as CasRef });
|
||||
|
||||
// Very large quota
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, 1000000, null, false);
|
||||
|
||||
// Should show all steps (no skipping)
|
||||
expect(markdown).not.toMatch(/earlier step/);
|
||||
expect(markdown).toMatch(/Step1/);
|
||||
expect(markdown).toMatch(/Step2/);
|
||||
expect(markdown).toMatch(/Step3/);
|
||||
});
|
||||
|
||||
test("test 6: quota with --before parameter", async () => {
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const detailSchemas = await registerDetailSchemas(store);
|
||||
|
||||
const workflowHash = await store.put(schemas.workflow, {
|
||||
name: "test-wf",
|
||||
description: "desc",
|
||||
roles: {
|
||||
worker: {
|
||||
description: "Worker",
|
||||
goal: "You are a worker agent.",
|
||||
capabilities: [],
|
||||
procedure: "Do the work.",
|
||||
output: "Summarize the work.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const startHash = await store.put(schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Test task",
|
||||
});
|
||||
|
||||
const outputHash = await store.put(schemas.workflow, {
|
||||
name: "out",
|
||||
description: "",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
// Create 5 steps
|
||||
const steps: CasRef[] = [];
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const content = generateContent(300, `Step${i}`);
|
||||
const turnHash = await store.put(detailSchemas.turn, {
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content,
|
||||
toolCalls: null,
|
||||
reasoning: null,
|
||||
});
|
||||
const detailHash = await store.put(detailSchemas.detail, {
|
||||
sessionId: `session-${i}`,
|
||||
model: "test-model",
|
||||
duration: 1000,
|
||||
turnCount: 1,
|
||||
turns: [turnHash],
|
||||
});
|
||||
const stepHash = await store.put(schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: steps[i - 2] ?? null,
|
||||
role: "worker",
|
||||
output: outputHash,
|
||||
detail: detailHash,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
steps.push(stepHash);
|
||||
}
|
||||
|
||||
const threadId = "01HX2Q3R4S5T6V7W8X9YZ6" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: steps[4] as CasRef });
|
||||
|
||||
// Use --before to limit to steps 1-2, then set quota that allows only 1
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, 500, steps[2] as CasRef, false);
|
||||
|
||||
// Should not contain Step3 or later
|
||||
expect(markdown).not.toMatch(/Step3/);
|
||||
expect(markdown).not.toMatch(/Step4/);
|
||||
expect(markdown).not.toMatch(/Step5/);
|
||||
|
||||
// Quota should select most recent of candidates (Step2)
|
||||
expect(markdown).toMatch(/Step2/);
|
||||
|
||||
// Quota enforcement (allow ~200 char tolerance)
|
||||
expect(markdown.length).toBeLessThanOrEqual(700);
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,6 @@ async function storeWorkflow(uwf: UwfStore, name: string): Promise<CasRef> {
|
||||
name,
|
||||
description: "Test workflow",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
};
|
||||
return await uwf.store.put(uwf.schemas.workflow, payload);
|
||||
@@ -36,7 +35,6 @@ async function createWorkflowYaml(name: string, version: string | null = null):
|
||||
name,
|
||||
description: version !== null ? `Test workflow (${version})` : "Test workflow",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
};
|
||||
const yaml = stringify(payload);
|
||||
@@ -145,7 +143,7 @@ describe("Strategy 2: File Path Resolution", () => {
|
||||
test("should fail on valid YAML with invalid WorkflowPayload shape", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const yamlPath = join(tmpDir, "invalid-workflow.yaml");
|
||||
await writeFile(yamlPath, "name: test\n# missing roles, conditions, and graph");
|
||||
await writeFile(yamlPath, "name: test\n# missing roles and graph");
|
||||
|
||||
await expect(cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot)).rejects.toThrow();
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { cmdLogClean, cmdLogList, cmdLogShow } from "./commands/log.js";
|
||||
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
|
||||
import { cmdSkillCli } from "./commands/skill.js";
|
||||
import { cmdStepFork, cmdStepList, cmdStepShow } from "./commands/step.js";
|
||||
import { cmdStepFork, cmdStepList, cmdStepRead, cmdStepShow } from "./commands/step.js";
|
||||
import {
|
||||
cmdThreadCancel,
|
||||
cmdThreadExec,
|
||||
@@ -346,7 +346,23 @@ step
|
||||
});
|
||||
});
|
||||
|
||||
// step read is not yet registered (half-baked, see step.ts cmdStepRead)
|
||||
step
|
||||
.command("read")
|
||||
.description("Read a step's turns as human-readable markdown")
|
||||
.argument("<step-hash>", "CAS hash of the StepNode")
|
||||
.option("--quota <chars>", "Max output characters", "4000")
|
||||
.action((stepHash: string, opts: { quota: string }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const quota = Number.parseInt(opts.quota, 10);
|
||||
if (!Number.isFinite(quota) || quota < 1) {
|
||||
process.stderr.write("invalid --quota: must be a positive integer\n");
|
||||
process.exit(1);
|
||||
}
|
||||
const markdown = await cmdStepRead(storageRoot, stepHash as CasRef, quota);
|
||||
process.stdout.write(markdown.endsWith("\n") ? markdown : `${markdown}\n`);
|
||||
});
|
||||
});
|
||||
|
||||
step
|
||||
.command("fork")
|
||||
@@ -549,7 +565,11 @@ cas
|
||||
.action((hash: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
writeOutput(await cmdCasHas(storageRoot, hash));
|
||||
const result = await cmdCasHas(storageRoot, hash);
|
||||
writeOutput(result);
|
||||
if (!result.exists) {
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { BootstrapCapableStore } from "@uncaged/json-cas";
|
||||
import type {
|
||||
CasRef,
|
||||
StartEntry,
|
||||
@@ -18,6 +19,11 @@ import {
|
||||
walkChain,
|
||||
} from "./shared.js";
|
||||
|
||||
type TurnData = {
|
||||
index: number;
|
||||
content: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* List all steps in a thread (previously: thread steps)
|
||||
*/
|
||||
@@ -111,13 +117,114 @@ export async function cmdStepFork(
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a step's agent output as markdown (new command - requires #462)
|
||||
* TODO: Implement once unified agent detail/turn schema is available
|
||||
* Load and validate step detail node from CAS store
|
||||
*/
|
||||
function loadStepDetail(store: BootstrapCapableStore, detailRef: CasRef): Record<string, unknown> {
|
||||
const detailNode = store.get(detailRef);
|
||||
if (detailNode === null) {
|
||||
fail(`detail node not found: ${detailRef}`);
|
||||
}
|
||||
return detailNode.payload as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all turn nodes from CAS store and extract content
|
||||
*/
|
||||
function loadTurnData(store: BootstrapCapableStore, turns: unknown): TurnData[] {
|
||||
if (!Array.isArray(turns) || turns.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const turnData: TurnData[] = [];
|
||||
for (const turnRef of turns) {
|
||||
if (typeof turnRef !== "string") {
|
||||
continue;
|
||||
}
|
||||
const turnNode = store.get(turnRef as CasRef);
|
||||
if (turnNode === null) {
|
||||
continue;
|
||||
}
|
||||
const turn = turnNode.payload as Record<string, unknown>;
|
||||
if (typeof turn.content === "string") {
|
||||
turnData.push({
|
||||
index: typeof turn.index === "number" ? turn.index : turnData.length,
|
||||
content: turn.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
return turnData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select turns that fit within quota, working backwards from most recent
|
||||
*/
|
||||
function selectTurnsForQuota(turnData: TurnData[], availableQuota: number): TurnData[] {
|
||||
const selectedTurns: TurnData[] = [];
|
||||
let totalChars = 0;
|
||||
|
||||
for (let i = turnData.length - 1; i >= 0; i--) {
|
||||
const turn = turnData[i];
|
||||
if (turn === undefined) continue;
|
||||
|
||||
const turnHeader = `## Turn ${turn.index + 1}\n\n`;
|
||||
const turnBlock = turnHeader + turn.content;
|
||||
const separatorCost = selectedTurns.length > 0 ? 2 : 0;
|
||||
const addCost = turnBlock.length + separatorCost;
|
||||
|
||||
if (totalChars + addCost > availableQuota && selectedTurns.length > 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
selectedTurns.unshift(turn);
|
||||
totalChars += addCost;
|
||||
}
|
||||
|
||||
return selectedTurns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble final markdown output from header and selected turns
|
||||
*/
|
||||
function formatStepMarkdown(
|
||||
stepHash: CasRef,
|
||||
role: string,
|
||||
agent: string,
|
||||
turnData: TurnData[],
|
||||
selectedTurns: TurnData[],
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(`# Step ${stepHash}`);
|
||||
parts.push("");
|
||||
parts.push(`**Role:** ${role}`);
|
||||
parts.push(`**Agent:** ${agent}`);
|
||||
|
||||
if (selectedTurns.length === 0) {
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
const skippedCount = turnData.length - selectedTurns.length;
|
||||
if (skippedCount > 0) {
|
||||
parts.push("");
|
||||
parts.push(`_[Earlier turns omitted due to quota. Use --quota to increase.]_`);
|
||||
}
|
||||
|
||||
for (const turn of selectedTurns) {
|
||||
parts.push("");
|
||||
parts.push(`## Turn ${turn.index + 1}`);
|
||||
parts.push("");
|
||||
parts.push(turn.content);
|
||||
}
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a step's agent turns as human-readable markdown with quota enforcement
|
||||
*/
|
||||
export async function cmdStepRead(
|
||||
storageRoot: string,
|
||||
stepHash: CasRef,
|
||||
_before: number | null = null,
|
||||
quota: number,
|
||||
): Promise<string> {
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const node = uwf.store.get(stepHash);
|
||||
@@ -128,18 +235,22 @@ export async function cmdStepRead(
|
||||
fail(`node ${stepHash} is not a StepNode`);
|
||||
}
|
||||
const payload = node.payload as StepNodePayload;
|
||||
if (!payload.output) {
|
||||
fail(`step ${stepHash} has no output`);
|
||||
|
||||
if (payload.detail === null) {
|
||||
return formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
|
||||
}
|
||||
|
||||
// TODO: Implement progressive turn reading with --before N
|
||||
// For now, return a placeholder
|
||||
const outputNode = uwf.store.get(payload.output);
|
||||
if (outputNode === null) {
|
||||
fail(`output node not found: ${payload.output}`);
|
||||
const detail = loadStepDetail(uwf.store, payload.detail);
|
||||
const turnData = loadTurnData(uwf.store, detail.turns);
|
||||
|
||||
if (turnData.length === 0) {
|
||||
return formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
|
||||
}
|
||||
|
||||
// Return the output as JSON for now
|
||||
// Once #462 is implemented, this will properly format frontmatter + markdown
|
||||
return JSON.stringify(outputNode.payload, null, 2);
|
||||
const headerSection = formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
|
||||
const BUFFER = 200;
|
||||
const availableQuota = quota - headerSection.length - BUFFER;
|
||||
const selectedTurns = selectTurnsForQuota(turnData, availableQuota);
|
||||
|
||||
return formatStepMarkdown(stepHash, payload.role, payload.agent, turnData, selectedTurns);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,8 @@ import type {
|
||||
AgentAlias,
|
||||
AgentConfig,
|
||||
CasRef,
|
||||
ModeratorContext,
|
||||
StartNodePayload,
|
||||
StartOutput,
|
||||
StepContext,
|
||||
StepNodePayload,
|
||||
StepOutput,
|
||||
ThreadId,
|
||||
@@ -27,7 +25,7 @@ import {
|
||||
type ProcessLogger,
|
||||
} from "@uncaged/workflow-util";
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
import { parse, stringify } from "yaml";
|
||||
import { parse } from "yaml";
|
||||
import { createMarker, deleteMarker, isThreadRunning } from "../background/index.js";
|
||||
import {
|
||||
appendThreadHistory,
|
||||
@@ -53,6 +51,7 @@ import {
|
||||
import { materializeWorkflowPayload } from "./workflow.js";
|
||||
|
||||
const END_ROLE = "$END";
|
||||
const START_ROLE = "$START";
|
||||
export const THREAD_READ_DEFAULT_QUOTA = 4000;
|
||||
|
||||
const PL_THREAD_START = "7HNQ4B2X";
|
||||
@@ -461,25 +460,6 @@ export async function cmdThreadList(
|
||||
return applyPagination(items, skip, take);
|
||||
}
|
||||
|
||||
function formatYaml(value: unknown): string {
|
||||
return stringify(value, { aliasDuplicateObjects: false }).trimEnd();
|
||||
}
|
||||
|
||||
function formatCompactStep(index: number, item: OrderedStepItem, outputYaml: string): string {
|
||||
return [
|
||||
`## Step ${index}: ${item.payload.role}`,
|
||||
"",
|
||||
`- **Hash:** \`${item.hash}\``,
|
||||
`- **Agent:** ${item.payload.agent}`,
|
||||
"",
|
||||
"### Output",
|
||||
"",
|
||||
"```yaml",
|
||||
outputYaml,
|
||||
"```",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function extractLastAssistantContent(uwf: UwfStore, detailRef: CasRef): string | null {
|
||||
const detailNode = uwf.store.get(detailRef);
|
||||
if (detailNode === null) {
|
||||
@@ -523,22 +503,60 @@ function sliceBeforeHash(
|
||||
return candidates.slice(0, idx);
|
||||
}
|
||||
|
||||
function calculateFormattedStepLength(
|
||||
stepNum: number,
|
||||
item: OrderedStepItem,
|
||||
uwf: UwfStore,
|
||||
workflow: WorkflowPayload,
|
||||
): number {
|
||||
// Calculate using the same format as formatStepHeader, formatStepPrompt, formatStepContent
|
||||
// Use a temporary set to avoid mutating the actual shownPromptRoles during calculation
|
||||
const tempShownRoles = new Set<string>();
|
||||
const header = formatStepHeader(stepNum, item);
|
||||
const roleDef = workflow.roles[item.payload.role];
|
||||
const prompt = formatStepPrompt(roleDef, item.payload.role, tempShownRoles);
|
||||
const content = formatStepContent(uwf, item);
|
||||
|
||||
const stepBlock = [header, prompt, content].filter((s) => s !== "").join("");
|
||||
|
||||
// Don't add separator here - it will be counted when we know the final structure
|
||||
return stepBlock.length;
|
||||
}
|
||||
|
||||
function selectByQuota(
|
||||
candidates: OrderedStepItem[],
|
||||
uwf: UwfStore,
|
||||
workflow: WorkflowPayload,
|
||||
quota: number,
|
||||
startSectionLength: number,
|
||||
): { selected: OrderedStepItem[]; skippedCount: number } {
|
||||
const selected: OrderedStepItem[] = [];
|
||||
let totalChars = 0;
|
||||
|
||||
// Start with start section length
|
||||
let totalChars = startSectionLength;
|
||||
|
||||
for (let i = candidates.length - 1; i >= 0; i--) {
|
||||
const item = candidates[i];
|
||||
if (item === undefined) continue;
|
||||
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
|
||||
const blockLen = formatCompactStep(i + 1, item, outputYaml).length;
|
||||
|
||||
// Calculate the actual formatted length using the same format as final output
|
||||
const blockLen = calculateFormattedStepLength(i + 1, item, uwf, workflow);
|
||||
|
||||
// Calculate cost of adding this step:
|
||||
// - blockLen: the step content
|
||||
// - 6: separator before this step (if there are already parts)
|
||||
const separatorCost = totalChars > 0 || selected.length > 0 ? 6 : 0;
|
||||
const addCost = blockLen + separatorCost;
|
||||
|
||||
// Check quota BEFORE adding - but always include at least one step
|
||||
if (totalChars + addCost > quota && selected.length > 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
selected.unshift(item);
|
||||
totalChars += blockLen;
|
||||
if (totalChars > quota) break;
|
||||
totalChars += addCost;
|
||||
}
|
||||
|
||||
return { selected, skippedCount: candidates.length - selected.length };
|
||||
}
|
||||
|
||||
@@ -605,11 +623,21 @@ function formatThreadReadMarkdown(options: {
|
||||
const { ordered, uwf, workflow, quota, before } = options;
|
||||
|
||||
const candidates = before !== null ? sliceBeforeHash(ordered, before, options.threadId) : ordered;
|
||||
const { selected, skippedCount } = selectByQuota(candidates, uwf, quota);
|
||||
|
||||
// Calculate start section length for quota accounting
|
||||
const startSection = formatStartSection(options);
|
||||
const startSectionLength = startSection !== "" ? startSection.length : 0;
|
||||
|
||||
const { selected, skippedCount } = selectByQuota(
|
||||
candidates,
|
||||
uwf,
|
||||
workflow,
|
||||
quota,
|
||||
startSectionLength,
|
||||
);
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
const startSection = formatStartSection(options);
|
||||
if (startSection !== "") parts.push(startSection);
|
||||
|
||||
if (skippedCount > 0 && selected.length > 0) {
|
||||
@@ -641,17 +669,32 @@ function formatThreadReadMarkdown(options: {
|
||||
return parts.join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorContext {
|
||||
const chronological = [...chain.stepsNewestFirst].reverse();
|
||||
const steps: StepContext[] = chronological.map((step) => ({
|
||||
role: step.role,
|
||||
output: expandOutput(uwf, step.output),
|
||||
detail: step.detail,
|
||||
agent: step.agent,
|
||||
edgePrompt: step.edgePrompt ?? "",
|
||||
content: null, // Moderator doesn't need content
|
||||
}));
|
||||
return { start: chain.start, steps };
|
||||
type EvaluateLastOutput = Record<string, unknown> & { status: string };
|
||||
|
||||
function resolveEvaluateArgs(
|
||||
uwf: UwfStore,
|
||||
chain: ChainState,
|
||||
): { lastRole: string; lastOutput: EvaluateLastOutput } {
|
||||
if (chain.headIsStart) {
|
||||
return { lastRole: START_ROLE, lastOutput: { status: "_" } };
|
||||
}
|
||||
|
||||
const lastStep = chain.stepsNewestFirst[0];
|
||||
if (lastStep === undefined) {
|
||||
fail("empty step chain");
|
||||
}
|
||||
|
||||
const raw = expandOutput(uwf, lastStep.output);
|
||||
const base =
|
||||
typeof raw === "object" && raw !== null && !Array.isArray(raw)
|
||||
? (raw as Record<string, unknown>)
|
||||
: {};
|
||||
const status = typeof base.status === "string" ? base.status : "_";
|
||||
|
||||
return {
|
||||
lastRole: lastStep.role,
|
||||
lastOutput: { ...base, status },
|
||||
};
|
||||
}
|
||||
|
||||
function loadWorkflowPayload(uwf: UwfStore, workflowRef: CasRef): WorkflowPayload {
|
||||
@@ -895,9 +938,9 @@ async function cmdThreadStepOnce(
|
||||
const chain = walkChain(uwf, headHash);
|
||||
const workflowHash = chain.start.workflow;
|
||||
const workflow = loadWorkflowPayload(uwf, workflowHash);
|
||||
const context = buildModeratorContext(uwf, chain);
|
||||
const { lastRole, lastOutput } = resolveEvaluateArgs(uwf, chain);
|
||||
|
||||
const nextResult = await evaluate(workflow, context);
|
||||
const nextResult = evaluate(workflow.graph, lastRole, lastOutput);
|
||||
if (!nextResult.ok) {
|
||||
failStep(plog, `moderator evaluate failed: ${nextResult.error.message}`);
|
||||
}
|
||||
@@ -947,8 +990,11 @@ async function cmdThreadStepOnce(
|
||||
await saveThreadsIndex(storageRoot, freshIndex);
|
||||
|
||||
const chainAfter = walkChain(uwfAfter, newHead);
|
||||
const contextAfter = buildModeratorContext(uwfAfter, chainAfter);
|
||||
const afterResult = await evaluate(workflow, contextAfter);
|
||||
const { lastRole: lastRoleAfter, lastOutput: lastOutputAfter } = resolveEvaluateArgs(
|
||||
uwfAfter,
|
||||
chainAfter,
|
||||
);
|
||||
const afterResult = evaluate(workflow.graph, lastRoleAfter, lastOutputAfter);
|
||||
if (!afterResult.ok) {
|
||||
failStep(plog, `post-step moderator evaluate failed: ${afterResult.error.message}`);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,7 @@ import { readFile } from "node:fs/promises";
|
||||
|
||||
import type { JSONSchema } from "@uncaged/json-cas";
|
||||
import { putSchema, validate } from "@uncaged/json-cas";
|
||||
import type {
|
||||
CasRef,
|
||||
RoleDefinition,
|
||||
Transition,
|
||||
WorkflowPayload,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import type { CasRef, RoleDefinition, Target, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import { parse } from "yaml";
|
||||
|
||||
import {
|
||||
@@ -51,20 +46,23 @@ function isJsonSchema(value: unknown): value is JSONSchema {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Normalize graph transitions: ensure condition is null (not undefined) for fallback entries. */
|
||||
function normalizeGraph(graph: Record<string, Transition[]>): Record<string, Transition[]> {
|
||||
const result: Record<string, Transition[]> = {};
|
||||
for (const [node, transitions] of Object.entries(graph)) {
|
||||
result[node] = transitions.map((t) => {
|
||||
if (typeof t.prompt !== "string" || t.prompt.trim() === "") {
|
||||
fail(`graph[${node}] transition to "${t.role}": prompt is required (non-empty string)`);
|
||||
/** Normalize graph: validate each status → target mapping. */
|
||||
function normalizeGraph(
|
||||
graph: Record<string, Record<string, Target>>,
|
||||
): Record<string, Record<string, Target>> {
|
||||
const result: Record<string, Record<string, Target>> = {};
|
||||
for (const [node, statusMap] of Object.entries(graph)) {
|
||||
const normalized: Record<string, Target> = {};
|
||||
for (const [status, target] of Object.entries(statusMap)) {
|
||||
if (typeof target.prompt !== "string" || target.prompt.trim() === "") {
|
||||
fail(`graph[${node}][${status}] → "${target.role}": prompt is required (non-empty string)`);
|
||||
}
|
||||
return {
|
||||
role: t.role,
|
||||
condition: t.condition ?? null,
|
||||
prompt: t.prompt,
|
||||
normalized[status] = {
|
||||
role: target.role,
|
||||
prompt: target.prompt,
|
||||
};
|
||||
});
|
||||
}
|
||||
result[node] = normalized;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -106,7 +104,6 @@ export async function materializeWorkflowPayload(
|
||||
name: raw.name,
|
||||
description: raw.description,
|
||||
roles,
|
||||
conditions: raw.conditions,
|
||||
graph: normalizeGraph(raw.graph),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,23 +30,12 @@ function isRoleDefinition(value: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isConditionDefinition(value: unknown): boolean {
|
||||
function isTarget(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
return typeof value.description === "string" && typeof value.expression === "string";
|
||||
}
|
||||
|
||||
function isTransition(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const condition = value.condition;
|
||||
return (
|
||||
typeof value.role === "string" &&
|
||||
typeof value.prompt === "string" &&
|
||||
value.prompt.trim() !== "" &&
|
||||
(condition === null || condition === undefined || typeof condition === "string")
|
||||
typeof value.role === "string" && typeof value.prompt === "string" && value.prompt.trim() !== ""
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,7 +51,7 @@ function isGraph(value: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
return Object.values(value).every(
|
||||
(transitions) => Array.isArray(transitions) && transitions.every((t) => isTransition(t)),
|
||||
(statusMap) => isRecord(statusMap) && Object.values(statusMap).every((t) => isTarget(t)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,11 +90,7 @@ export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
|
||||
if (typeof raw.name !== "string" || typeof raw.description !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!isStringRecord(raw.roles, isRoleDefinition) ||
|
||||
!isStringRecord(raw.conditions, isConditionDefinition) ||
|
||||
!isGraph(raw.graph)
|
||||
) {
|
||||
if (!isStringRecord(raw.roles, isRoleDefinition) || !isGraph(raw.graph)) {
|
||||
return null;
|
||||
}
|
||||
return raw as WorkflowPayload;
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("buildClaudeCodePrompt", () => {
|
||||
expect(result).toContain("## Task\nFix the bug");
|
||||
});
|
||||
|
||||
test("includes previous steps as history summary", () => {
|
||||
test("includes previous steps with content on first visit", () => {
|
||||
const ctx = makeCtx({
|
||||
steps: [
|
||||
{
|
||||
@@ -48,18 +48,50 @@ describe("buildClaudeCodePrompt", () => {
|
||||
agent: "hermes",
|
||||
detail: "detail-1",
|
||||
edgePrompt: "Create a plan.",
|
||||
content: "Here is my detailed plan for doing X.",
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = buildClaudeCodePrompt(ctx);
|
||||
expect(result).toContain("## Previous Steps");
|
||||
expect(result).toContain("## What Happened Since Your Last Turn");
|
||||
expect(result).toContain("Step 1: planner");
|
||||
expect(result).toContain("do X");
|
||||
// First visit should include step content
|
||||
expect(result).toContain("Here is my detailed plan for doing X.");
|
||||
});
|
||||
|
||||
test("re-entry shows steps since last visit without content", () => {
|
||||
const ctx = makeCtx({
|
||||
isFirstVisit: false,
|
||||
steps: [
|
||||
{
|
||||
role: "developer",
|
||||
output: '{"status":"done"}',
|
||||
agent: "claude-code",
|
||||
detail: "detail-1",
|
||||
edgePrompt: "Implement.",
|
||||
content: "I implemented everything.",
|
||||
},
|
||||
{
|
||||
role: "reviewer",
|
||||
output: '{"approved":false}',
|
||||
agent: "claude-code",
|
||||
detail: "detail-2",
|
||||
edgePrompt: "Review.",
|
||||
content: "Rejected: complexity too high, refactor cmdStepRead.",
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = buildClaudeCodePrompt(ctx);
|
||||
expect(result).toContain("## What Happened Since Your Last Turn");
|
||||
expect(result).toContain("reviewer");
|
||||
expect(result).toContain("approved");
|
||||
});
|
||||
|
||||
test("omits history section when steps array is empty", () => {
|
||||
const result = buildClaudeCodePrompt(makeCtx({ steps: [] }));
|
||||
expect(result).not.toContain("## Previous Steps");
|
||||
expect(result).not.toContain("## What Happened Since Your Last Turn");
|
||||
expect(result).toContain("## Current Instruction");
|
||||
});
|
||||
|
||||
test("works without outputFormatInstruction", () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Store } from "@uncaged/json-cas";
|
||||
import {
|
||||
type AgentContext,
|
||||
type AgentRunResult,
|
||||
buildContinuationPrompt,
|
||||
buildRolePrompt,
|
||||
createAgent,
|
||||
getCachedSessionId,
|
||||
@@ -18,25 +19,6 @@ const CLAUDE_COMMAND = "claude";
|
||||
const CLAUDE_MAX_TURNS = 90;
|
||||
const CLAUDE_MODEL = process.env.CLAUDE_MODEL ?? null;
|
||||
|
||||
function buildHistorySummary(steps: AgentContext["steps"]): string {
|
||||
if (steps.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const lines: string[] = ["## Previous Steps"];
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
if (step === undefined) {
|
||||
continue;
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(`### Step ${i + 1}: ${step.role}`);
|
||||
lines.push(`Output: ${JSON.stringify(step.output)}`);
|
||||
lines.push(`Agent: ${step.agent}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Assemble system prompt, task, and prior step outputs for Claude Code. */
|
||||
export function buildClaudeCodePrompt(ctx: AgentContext): string {
|
||||
const roleDef = ctx.workflow.roles[ctx.role];
|
||||
@@ -46,11 +28,23 @@ export function buildClaudeCodePrompt(ctx: AgentContext): string {
|
||||
parts.push(ctx.outputFormatInstruction, "");
|
||||
}
|
||||
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
|
||||
const historyBlock = buildHistorySummary(ctx.steps);
|
||||
if (historyBlock !== "") {
|
||||
parts.push("", historyBlock);
|
||||
|
||||
if (!ctx.isFirstVisit) {
|
||||
// Re-entry (session will be resumed): show only steps since last visit, meta only
|
||||
parts.push("", buildContinuationPrompt(ctx.steps, ctx.role, ctx.edgePrompt));
|
||||
} else if (ctx.steps.length > 0) {
|
||||
// First visit: show all steps with content for recent ones
|
||||
parts.push(
|
||||
"",
|
||||
buildContinuationPrompt(ctx.steps, ctx.role, ctx.edgePrompt, {
|
||||
includeContent: true,
|
||||
quota: 32000,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
parts.push("", "## Current Instruction", "", ctx.edgePrompt);
|
||||
}
|
||||
parts.push("", "## Current Instruction", "", ctx.edgePrompt);
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -83,9 +83,10 @@ Requires `UWF_EDGE_PROMPT` in the environment (set by `uwf thread step`).
|
||||
function buildRolePrompt(role: RoleDefinition): string
|
||||
function buildOutputFormatInstruction(schema: JSONSchema): string
|
||||
function buildContinuationPrompt(
|
||||
ctx: AgentContext,
|
||||
priorOutput: string,
|
||||
instruction: string,
|
||||
steps: StepContext[],
|
||||
role: string,
|
||||
edgePrompt: string,
|
||||
options?: { includeContent?: boolean; quota?: number },
|
||||
): string
|
||||
```
|
||||
|
||||
|
||||
@@ -1,312 +1,122 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ModeratorContext, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import type { Target, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { evaluate } from "../src/evaluate.js";
|
||||
|
||||
const solveIssueWorkflow: WorkflowPayload = {
|
||||
name: "solve-issue",
|
||||
description: "End-to-end issue resolution",
|
||||
roles: {
|
||||
planner: {
|
||||
description: "Creates implementation plan",
|
||||
goal: "You are a planning agent.",
|
||||
capabilities: ["planning"],
|
||||
procedure: "Create a step-by-step plan.",
|
||||
output: "Output the plan and steps.",
|
||||
frontmatter: "5GWKR8TN1V3JA",
|
||||
},
|
||||
developer: {
|
||||
description: "Implements code changes",
|
||||
goal: "You are a developer agent.",
|
||||
capabilities: ["coding"],
|
||||
procedure: "Implement the plan.",
|
||||
output: "List files changed and summary.",
|
||||
frontmatter: "8CNWT4KR6D1HV",
|
||||
},
|
||||
reviewer: {
|
||||
description: "Reviews code changes",
|
||||
goal: "You are a code reviewer.",
|
||||
capabilities: ["code-review"],
|
||||
procedure: "Review the implementation.",
|
||||
output: "Approve or reject with comments.",
|
||||
frontmatter: "1VPBG9SM5E7WK",
|
||||
},
|
||||
const solveIssueGraph: WorkflowPayload["graph"] = {
|
||||
$START: {
|
||||
_: { role: "planner", prompt: "Start planning from the issue in the task." },
|
||||
},
|
||||
conditions: {
|
||||
needsClarification: {
|
||||
description: "Planner requests clarification from user",
|
||||
expression: "$exists($last('planner').needsClarification)",
|
||||
},
|
||||
rejected: {
|
||||
description: "Reviewer rejected the implementation",
|
||||
expression: "$last('reviewer').approved = false",
|
||||
},
|
||||
planner: {
|
||||
_: { role: "developer", prompt: "Implement the plan: {{plan}}" },
|
||||
},
|
||||
graph: {
|
||||
$START: [
|
||||
{
|
||||
role: "planner",
|
||||
condition: null,
|
||||
prompt: "Start planning from the issue in the task.",
|
||||
},
|
||||
],
|
||||
planner: [
|
||||
{
|
||||
role: "developer",
|
||||
condition: "needsClarification",
|
||||
prompt: "Clarification is needed; hand off to developer.",
|
||||
},
|
||||
{ role: "$END", condition: null, prompt: "Planning complete; end workflow." },
|
||||
],
|
||||
developer: [
|
||||
{
|
||||
role: "reviewer",
|
||||
condition: null,
|
||||
prompt: "Implementation done; send to reviewer.",
|
||||
},
|
||||
],
|
||||
reviewer: [
|
||||
{
|
||||
role: "developer",
|
||||
condition: "rejected",
|
||||
prompt: "Reviewer rejected; return to developer.",
|
||||
},
|
||||
{ role: "$END", condition: null, prompt: "Review passed; end workflow." },
|
||||
],
|
||||
developer: {
|
||||
_: { role: "reviewer", prompt: "Review the changes: {{summary}}" },
|
||||
},
|
||||
reviewer: {
|
||||
approved: { role: "$END", prompt: "Done." },
|
||||
rejected: { role: "developer", prompt: "Fix: {{comments}}" },
|
||||
},
|
||||
};
|
||||
|
||||
function makeContext(steps: ModeratorContext["steps"]): ModeratorContext {
|
||||
return {
|
||||
start: {
|
||||
workflow: "4KNM2PXR3B1QW",
|
||||
prompt: "Fix the login bug",
|
||||
},
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
describe("evaluate", () => {
|
||||
test("$START → first role (fallback)", async () => {
|
||||
const result = await evaluate(solveIssueWorkflow, makeContext([]));
|
||||
test("$START → first role (unit status _)", () => {
|
||||
const result = evaluate(solveIssueGraph, "$START", { status: "_" });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "planner", prompt: "Start planning from the issue in the task." },
|
||||
});
|
||||
});
|
||||
|
||||
test("condition match (rejected → developer)", async () => {
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "reviewer",
|
||||
output: { approved: false },
|
||||
detail: "2MXBG6PN4A8JR",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(solveIssueWorkflow, context);
|
||||
test("status-based routing (reviewer rejected → developer)", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", {
|
||||
status: "rejected",
|
||||
comments: "missing tests",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Reviewer rejected; return to developer." },
|
||||
value: { role: "developer", prompt: "Fix: missing tests" },
|
||||
});
|
||||
});
|
||||
|
||||
test("fallback when condition does not match → $END", async () => {
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "reviewer",
|
||||
output: { approved: true },
|
||||
detail: "2MXBG6PN4A8JR",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(solveIssueWorkflow, context);
|
||||
test("status-based routing (reviewer approved → $END)", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", { status: "approved" });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "$END", prompt: "Review passed; end workflow." },
|
||||
value: { role: "$END", prompt: "Done." },
|
||||
});
|
||||
});
|
||||
|
||||
test("missing role in graph → error", async () => {
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "unknown-role",
|
||||
output: {},
|
||||
detail: "2MXBG6PN4A8JR",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(solveIssueWorkflow, context);
|
||||
test("missing role in graph → error", () => {
|
||||
const result = evaluate(solveIssueGraph, "unknown-role", { status: "_" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe('no transitions defined for role "unknown-role"');
|
||||
}
|
||||
});
|
||||
|
||||
test("output expansion in context works with JSONata", async () => {
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "planner",
|
||||
output: { needsClarification: true },
|
||||
detail: "7BQST3VW9F2MA",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(solveIssueWorkflow, context);
|
||||
test("missing status in graph → error", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", { status: "pending" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe('no transition for role "reviewer" with status "pending"');
|
||||
}
|
||||
});
|
||||
|
||||
test("mustache template rendering with simple fields", () => {
|
||||
const result = evaluate(solveIssueGraph, "planner", {
|
||||
status: "_",
|
||||
plan: "Add auth middleware",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Clarification is needed; hand off to developer." },
|
||||
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" },
|
||||
});
|
||||
});
|
||||
|
||||
test("$last returns most recent matching role's frontmatter", async () => {
|
||||
const workflow: WorkflowPayload = {
|
||||
...solveIssueWorkflow,
|
||||
conditions: {
|
||||
devFailed: {
|
||||
description: "Developer failed",
|
||||
expression: "$last('developer').status = 'failed'",
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: [
|
||||
{
|
||||
role: "developer",
|
||||
condition: null,
|
||||
prompt: "Begin development.",
|
||||
},
|
||||
],
|
||||
developer: [
|
||||
{ role: "$END", condition: "devFailed", prompt: "Development failed; end." },
|
||||
{
|
||||
role: "reviewer",
|
||||
condition: null,
|
||||
prompt: "Development succeeded; review.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "developer",
|
||||
output: { status: "done" },
|
||||
detail: "1VPBG9SM5E7WK",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
{
|
||||
role: "reviewer",
|
||||
output: { approved: false },
|
||||
detail: "2MXBG6PN4A8JR",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
{
|
||||
role: "developer",
|
||||
output: { status: "failed" },
|
||||
detail: "3QNTH7WK8D2PA",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(workflow, context);
|
||||
test("mustache does not HTML-escape prompt content", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", {
|
||||
status: "rejected",
|
||||
comments: 'use <T> & "Result<T, E>" types',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "$END", prompt: "Development failed; end." },
|
||||
value: { role: "developer", prompt: 'Fix: use <T> & "Result<T, E>" types' },
|
||||
});
|
||||
});
|
||||
|
||||
test("$first returns earliest matching role's frontmatter", async () => {
|
||||
const workflow: WorkflowPayload = {
|
||||
...solveIssueWorkflow,
|
||||
conditions: {
|
||||
firstPlanReady: {
|
||||
description: "First planner run was ready",
|
||||
expression: "$first('planner').status = 'ready'",
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: [
|
||||
{
|
||||
role: "planner",
|
||||
condition: null,
|
||||
prompt: "Begin planning.",
|
||||
},
|
||||
],
|
||||
planner: [
|
||||
{ role: "$END", condition: "firstPlanReady", prompt: "First plan was ready; end." },
|
||||
{
|
||||
role: "developer",
|
||||
condition: null,
|
||||
prompt: "Plan not ready on first pass; implement.",
|
||||
},
|
||||
],
|
||||
test("triple mustache also works for unescaped output", () => {
|
||||
const graph: Record<string, Record<string, Target>> = {
|
||||
reviewer: {
|
||||
_: { role: "developer", prompt: "Fix: {{{comments}}}" },
|
||||
},
|
||||
};
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "planner",
|
||||
output: { status: "ready", plan: "ABC123" },
|
||||
detail: "7BQST3VW9F2MA",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
{
|
||||
role: "developer",
|
||||
output: { status: "done" },
|
||||
detail: "1VPBG9SM5E7WK",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
{
|
||||
role: "planner",
|
||||
output: { status: "revised", plan: "DEF456" },
|
||||
detail: "4RNMK6PX8B3WQ",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(workflow, context);
|
||||
const result = evaluate(graph, "reviewer", {
|
||||
status: "_",
|
||||
comments: "<script>alert(1)</script>",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "$END", prompt: "First plan was ready; end." },
|
||||
value: { role: "developer", prompt: "Fix: <script>alert(1)</script>" },
|
||||
});
|
||||
});
|
||||
|
||||
test("$last returns undefined for unmatched role", async () => {
|
||||
const workflow: WorkflowPayload = {
|
||||
...solveIssueWorkflow,
|
||||
conditions: {
|
||||
hasReviewer: {
|
||||
description: "Reviewer has run",
|
||||
expression: "$exists($last('reviewer'))",
|
||||
test("mustache template with nested object paths", () => {
|
||||
const graph: Record<string, Record<string, Target>> = {
|
||||
reviewer: {
|
||||
_: {
|
||||
role: "developer",
|
||||
prompt: "Address: {{review.comments}}",
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: [
|
||||
{
|
||||
role: "planner",
|
||||
condition: null,
|
||||
prompt: "Begin planning.",
|
||||
},
|
||||
],
|
||||
planner: [
|
||||
{ role: "$END", condition: "hasReviewer", prompt: "Reviewer already ran; end." },
|
||||
{
|
||||
role: "developer",
|
||||
condition: null,
|
||||
prompt: "No reviewer yet; implement.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "planner",
|
||||
output: { status: "ready" },
|
||||
detail: "7BQST3VW9F2MA",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(workflow, context);
|
||||
// no reviewer step → $exists returns false → fallback to developer
|
||||
const result = evaluate(graph, "reviewer", {
|
||||
status: "_",
|
||||
review: { comments: "refactor the handler" },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "No reviewer yet; implement." },
|
||||
value: { role: "developer", prompt: "Address: refactor the handler" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,9 +19,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"jsonata": "^1.8.7"
|
||||
"mustache": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mustache": "^4.2.6",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,65 +1,42 @@
|
||||
import type { ModeratorContext, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import jsonata from "jsonata";
|
||||
import type { Target } from "@uncaged/workflow-protocol";
|
||||
import mustache from "mustache";
|
||||
|
||||
import type { EvaluateResult, Result } from "./types.js";
|
||||
|
||||
// Disable HTML escaping — prompts are plain text, not HTML.
|
||||
mustache.escape = (text: string) => text;
|
||||
|
||||
const START_ROLE = "$START";
|
||||
const UNIT_STATUS = "_";
|
||||
|
||||
function isTruthy(value: unknown): boolean {
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return value !== 0 && !Number.isNaN(value);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.length > 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
type LastOutput = Record<string, unknown> & { status: string };
|
||||
|
||||
function findByRole(
|
||||
steps: ModeratorContext["steps"],
|
||||
role: string,
|
||||
direction: "first" | "last",
|
||||
): unknown {
|
||||
if (direction === "last") {
|
||||
for (let i = steps.length - 1; i >= 0; i--) {
|
||||
if (steps[i].role === role) {
|
||||
return steps[i].output;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const step of steps) {
|
||||
if (step.role === role) {
|
||||
return step.output;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
export function evaluate(
|
||||
graph: Record<string, Record<string, Target>>,
|
||||
lastRole: string,
|
||||
lastOutput: LastOutput,
|
||||
): Result<EvaluateResult, Error> {
|
||||
const status = lastRole === START_ROLE ? UNIT_STATUS : lastOutput.status;
|
||||
|
||||
const roleTargets = graph[lastRole];
|
||||
if (roleTargets === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`no transitions defined for role "${lastRole}"`),
|
||||
};
|
||||
}
|
||||
|
||||
const target = roleTargets[status];
|
||||
if (target === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`no transition for role "${lastRole}" with status "${status}"`),
|
||||
};
|
||||
}
|
||||
|
||||
async function evaluateJsonata(
|
||||
expression: string,
|
||||
context: ModeratorContext,
|
||||
): Promise<Result<unknown, Error>> {
|
||||
try {
|
||||
const expr = jsonata(expression);
|
||||
expr.registerFunction(
|
||||
"first",
|
||||
(role: string) => findByRole(context.steps, role, "first"),
|
||||
"<s:x>",
|
||||
);
|
||||
expr.registerFunction(
|
||||
"last",
|
||||
(role: string) => findByRole(context.steps, role, "last"),
|
||||
"<s:x>",
|
||||
);
|
||||
const result = await expr.evaluate(context);
|
||||
return { ok: true, value: result };
|
||||
const prompt = mustache.render(target.prompt, lastOutput);
|
||||
return { ok: true, value: { role: target.role, prompt } };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -67,51 +44,3 @@ async function evaluateJsonata(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function currentRole(context: ModeratorContext): string {
|
||||
if (context.steps.length === 0) {
|
||||
return START_ROLE;
|
||||
}
|
||||
return context.steps[context.steps.length - 1].role;
|
||||
}
|
||||
|
||||
export async function evaluate(
|
||||
workflow: WorkflowPayload,
|
||||
context: ModeratorContext,
|
||||
): Promise<Result<EvaluateResult, Error>> {
|
||||
const role = currentRole(context);
|
||||
const transitions = workflow.graph[role];
|
||||
if (transitions === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`no transitions defined for role "${role}"`),
|
||||
};
|
||||
}
|
||||
|
||||
for (const transition of transitions) {
|
||||
if (transition.condition === null) {
|
||||
return { ok: true, value: { role: transition.role, prompt: transition.prompt } };
|
||||
}
|
||||
|
||||
const conditionDef = workflow.conditions[transition.condition];
|
||||
if (conditionDef === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`unknown condition "${transition.condition}"`),
|
||||
};
|
||||
}
|
||||
|
||||
const evalResult = await evaluateJsonata(conditionDef.expression, context);
|
||||
if (!evalResult.ok) {
|
||||
return evalResult;
|
||||
}
|
||||
if (isTruthy(evalResult.value)) {
|
||||
return { ok: true, value: { role: transition.role, prompt: transition.prompt } };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`no transition matched for role "${role}"`),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ type StepNodePayload = StepRecord & {
|
||||
### Moderator context
|
||||
|
||||
```typescript
|
||||
type StepContext = Omit<StepRecord, "output"> & { output: unknown };
|
||||
type StepContext = Omit<StepRecord, "output"> & { output: unknown; content: string | null };
|
||||
|
||||
type ModeratorContext = {
|
||||
start: StartNodePayload;
|
||||
|
||||
@@ -7,7 +7,6 @@ export type {
|
||||
AgentAlias,
|
||||
AgentConfig,
|
||||
CasRef,
|
||||
ConditionDefinition,
|
||||
ModelAlias,
|
||||
ModelConfig,
|
||||
ModeratorContext,
|
||||
@@ -26,12 +25,12 @@ export type {
|
||||
StepNodePayload,
|
||||
StepOutput,
|
||||
StepRecord,
|
||||
Target,
|
||||
ThreadForkOutput,
|
||||
ThreadId,
|
||||
ThreadListItem,
|
||||
ThreadStepsOutput,
|
||||
ThreadsIndex,
|
||||
Transition,
|
||||
WorkflowConfig,
|
||||
WorkflowName,
|
||||
WorkflowPayload,
|
||||
|
||||
@@ -14,22 +14,11 @@ const ROLE_DEFINITION: JSONSchema = {
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const CONDITION_DEFINITION: JSONSchema = {
|
||||
const TARGET: JSONSchema = {
|
||||
type: "object",
|
||||
required: ["description", "expression"],
|
||||
properties: {
|
||||
description: { type: "string" },
|
||||
expression: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const TRANSITION: JSONSchema = {
|
||||
type: "object",
|
||||
required: ["role", "condition", "prompt"],
|
||||
required: ["role", "prompt"],
|
||||
properties: {
|
||||
role: { type: "string" },
|
||||
condition: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
prompt: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
@@ -38,7 +27,7 @@ const TRANSITION: JSONSchema = {
|
||||
export const WORKFLOW_SCHEMA: JSONSchema = {
|
||||
title: "Workflow",
|
||||
type: "object",
|
||||
required: ["name", "description", "roles", "conditions", "graph"],
|
||||
required: ["name", "description", "roles", "graph"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
description: { type: "string" },
|
||||
@@ -46,15 +35,11 @@ export const WORKFLOW_SCHEMA: JSONSchema = {
|
||||
type: "object",
|
||||
additionalProperties: ROLE_DEFINITION,
|
||||
},
|
||||
conditions: {
|
||||
type: "object",
|
||||
additionalProperties: CONDITION_DEFINITION,
|
||||
},
|
||||
graph: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
type: "array",
|
||||
items: TRANSITION,
|
||||
type: "object",
|
||||
additionalProperties: TARGET,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -27,23 +27,16 @@ export type RoleDefinition = {
|
||||
frontmatter: CasRef;
|
||||
};
|
||||
|
||||
export type Transition = {
|
||||
export type Target = {
|
||||
role: string;
|
||||
condition: string | null;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export type ConditionDefinition = {
|
||||
description: string;
|
||||
expression: string;
|
||||
};
|
||||
|
||||
export type WorkflowPayload = {
|
||||
name: string;
|
||||
description: string;
|
||||
roles: Record<string, RoleDefinition>;
|
||||
conditions: Record<string, ConditionDefinition>;
|
||||
graph: Record<string, Transition[]>;
|
||||
graph: Record<string, Record<string, Target>>;
|
||||
};
|
||||
|
||||
// ── 4.3 Thread 节点 ─────────────────────────────────────────────────
|
||||
|
||||
@@ -23,6 +23,7 @@ All exports come from `src/index.ts`.
|
||||
```typescript
|
||||
function encodeUint64AsCrockford(value: bigint): string
|
||||
function generateUlid(nowMs: number): string
|
||||
function extractUlidTimestamp(ulid: string): number | null
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
||||
Reference in New Issue
Block a user