Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 984e6ae56d | |||
| 92f3b36b10 | |||
| a4677f8adb | |||
| 9ab6291a41 | |||
| 50a4db72b1 | |||
| dfdf0ac073 | |||
| c2c849df7e | |||
| 39f6ae692b | |||
| eb027e70f4 | |||
| 8fbbbce07e | |||
| f115718564 | |||
| 5c0eabda8e |
@@ -137,8 +137,11 @@ roles:
|
|||||||
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
|
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
|
||||||
3. Push the branch: `git push -u origin <branch-name>`
|
3. Push the branch: `git push -u origin <branch-name>`
|
||||||
- If push hook fails: capture the error log in your output, mark hook_failed
|
- If push hook fails: capture the error log in your output, mark hook_failed
|
||||||
4. On push success: create a PR via `tea pr create --title "..." --description "..."`
|
4. On push success: create a PR via `tea pr create --repo uncaged/workflow --title "..." --description "..."`
|
||||||
|
- The `--repo` flag is required to work in worktree directories (fixes #474 "path segment [0] is empty" error)
|
||||||
|
- If working on a different repo, extract owner/repo from: `git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/'`
|
||||||
- PR description must follow the project template: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
|
- PR description must follow the project template: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
|
||||||
|
- On tea failure: capture stderr/stdout, log the error clearly, include PR details (title, description, branch) for manual creation, and mark success=false
|
||||||
5. After PR creation, clean up the worktree:
|
5. After PR creation, clean up the worktree:
|
||||||
- `cd ~/repos/workflow`
|
- `cd ~/repos/workflow`
|
||||||
- `git worktree remove ~/repos/workflow-worktrees/fix/<issue-number>-<slug>`
|
- `git worktree remove ~/repos/workflow-worktrees/fix/<issue-number>-<slug>`
|
||||||
|
|||||||
@@ -62,16 +62,16 @@ See [docs/architecture.md](docs/architecture.md) for the full design — three-p
|
|||||||
uwf setup
|
uwf setup
|
||||||
|
|
||||||
# 2. Register a workflow from YAML
|
# 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)
|
# 3. Start a thread (creates head pointer; does not execute)
|
||||||
uwf thread start solve-issue -p "Fix the login redirect bug"
|
uwf thread start solve-issue -p "Fix the login redirect bug"
|
||||||
|
|
||||||
# 4. Execute steps (one at a time, until done)
|
# 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
|
## CLI Reference
|
||||||
|
|
||||||
@@ -79,8 +79,9 @@ Global options: `-V, --version`, `--format <json|yaml>`, `-h, --help`.
|
|||||||
|
|
||||||
| Group | Commands |
|
| Group | Commands |
|
||||||
|-------|----------|
|
|-------|----------|
|
||||||
| **thread** | `start`, `step`, `show`, `list`, `kill`, `steps`, `read`, `fork`, `step-details` |
|
| **thread** | `start`, `exec`, `show`, `list`, `stop`, `cancel`, `read` |
|
||||||
| **workflow** | `put`, `show`, `list` |
|
| **step** | `list`, `show`, `fork` |
|
||||||
|
| **workflow** | `add`, `show`, `list` |
|
||||||
| **cas** | `get`, `put`, `put-text`, `has`, `refs`, `walk`, `reindex`, `schema list`, `schema get` |
|
| **cas** | `get`, `put`, `put-text`, `has`, `refs`, `walk`, `reindex`, `schema list`, `schema get` |
|
||||||
| **setup** | Interactive or `--provider`, `--base-url`, `--api-key`, `--model`, `--agent` |
|
| **setup** | Interactive or `--provider`, `--base-url`, `--api-key`, `--model`, `--agent` |
|
||||||
| **skill** | `cli` — print markdown reference of all uwf commands |
|
| **skill** | `cli` — print markdown reference of all uwf commands |
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ roles:
|
|||||||
2. cd to the repoPath before making any changes.
|
2. cd to the repoPath before making any changes.
|
||||||
3. Create a feature branch from the default branch.
|
3. Create a feature branch from the default branch.
|
||||||
4. Implement the plan — write code, tests, and ensure existing tests pass.
|
4. Implement the plan — write code, tests, and ensure existing tests pass.
|
||||||
5. Commit your changes with a descriptive message referencing the issue.
|
5. Run the project's lint/check command (e.g. `bun run check`, `npm run lint`) and fix ALL errors before proceeding. Build and lint must pass cleanly.
|
||||||
|
6. Commit your changes with a descriptive message referencing the issue.
|
||||||
output: "List all files changed and provide a summary of the implementation."
|
output: "List all files changed and provide a summary of the implementation."
|
||||||
frontmatter:
|
frontmatter:
|
||||||
type: object
|
type: object
|
||||||
@@ -62,7 +63,10 @@ roles:
|
|||||||
capabilities:
|
capabilities:
|
||||||
- code-review
|
- code-review
|
||||||
- static-analysis
|
- static-analysis
|
||||||
procedure: "Review the implementation against the plan. Check for bugs, edge cases, and style."
|
procedure: |
|
||||||
|
1. Run hard checks first — build (`bun run build` or equivalent) and lint (`bunx biome check .` or equivalent) MUST pass with zero errors. If they fail, reject immediately.
|
||||||
|
2. Then review code quality: correctness, edge cases, naming, project conventions (CLAUDE.md), and test coverage.
|
||||||
|
3. Only reject for hard check failures or genuine correctness/security issues. Style suggestions alone should not block approval.
|
||||||
output: "Approve or reject with detailed comments explaining your decision."
|
output: "Approve or reject with detailed comments explaining your decision."
|
||||||
frontmatter:
|
frontmatter:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -531,13 +531,25 @@ export async function executeThread(
|
|||||||
timestamp: nowMs,
|
timestamp: nowMs,
|
||||||
parentState: options.parentStateHash,
|
parentState: options.parentStateHash,
|
||||||
},
|
},
|
||||||
steps: input.steps.map((out, i) => ({
|
steps: await Promise.all(
|
||||||
role: out.role,
|
input.steps.map(async (out, i) => {
|
||||||
contentHash: out.contentHash,
|
// Resolve content for the last step (most relevant for the next agent).
|
||||||
meta: out.meta,
|
// Earlier steps only carry meta summaries to avoid bloating the prompt.
|
||||||
refs: out.refs,
|
const isLast = i === input.steps.length - 1;
|
||||||
timestamp: replayTs?.[i] ?? prefilled?.[i]?.timestamp ?? nowMs + i,
|
let content: string | null = null;
|
||||||
})),
|
if (isLast) {
|
||||||
|
content = await getContentMerklePayload(io.cas, out.contentHash);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
role: out.role,
|
||||||
|
contentHash: out.contentHash,
|
||||||
|
content,
|
||||||
|
meta: out.meta,
|
||||||
|
refs: out.refs,
|
||||||
|
timestamp: replayTs?.[i] ?? prefilled?.[i]?.timestamp ?? nowMs + i,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const runtime: WorkflowRuntime = {
|
const runtime: WorkflowRuntime = {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export type RoleStep<M extends RoleMeta> = {
|
|||||||
role: K;
|
role: K;
|
||||||
meta: M[K];
|
meta: M[K];
|
||||||
contentHash: string;
|
contentHash: string;
|
||||||
|
content: string | null;
|
||||||
refs: string[];
|
refs: string[];
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ async function buildRoleStepsFromStates<M extends RoleMeta>(
|
|||||||
cas: CasStore,
|
cas: CasStore,
|
||||||
): Promise<RoleStep<M>[]> {
|
): Promise<RoleStep<M>[]> {
|
||||||
const steps: RoleStep<M>[] = [];
|
const steps: RoleStep<M>[] = [];
|
||||||
for (const st of chronologicalStates) {
|
for (let idx = 0; idx < chronologicalStates.length; idx++) {
|
||||||
|
const st = chronologicalStates[idx];
|
||||||
if (st.payload.role === END) {
|
if (st.payload.role === END) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -79,10 +80,13 @@ async function buildRoleStepsFromStates<M extends RoleMeta>(
|
|||||||
if (contentParsed === null || contentParsed.kind !== "content") {
|
if (contentParsed === null || contentParsed.kind !== "content") {
|
||||||
throw new Error(`buildThreadContext: expected content node at ${st.payload.content}`);
|
throw new Error(`buildThreadContext: expected content node at ${st.payload.content}`);
|
||||||
}
|
}
|
||||||
|
// Resolve full text content for the last step only
|
||||||
|
const isLast = idx === chronologicalStates.length - 1;
|
||||||
steps.push({
|
steps.push({
|
||||||
role: st.payload.role,
|
role: st.payload.role,
|
||||||
meta: st.payload.meta,
|
meta: st.payload.meta,
|
||||||
contentHash: st.payload.content,
|
contentHash: st.payload.content,
|
||||||
|
content: isLast ? contentParsed.node.payload : null,
|
||||||
refs: [...contentParsed.node.refs],
|
refs: [...contentParsed.node.refs],
|
||||||
timestamp: st.payload.timestamp,
|
timestamp: st.payload.timestamp,
|
||||||
} as RoleStep<M>);
|
} as RoleStep<M>);
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ async function advanceOneRound<M extends RoleMeta>(
|
|||||||
const step = {
|
const step = {
|
||||||
role: next,
|
role: next,
|
||||||
contentHash,
|
contentHash,
|
||||||
|
content: contentPayload,
|
||||||
meta,
|
meta,
|
||||||
refs,
|
refs,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
expect(text).not.toContain("## Tools");
|
expect(text).not.toContain("## Tools");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("single step shows hash and meta, and includes tools", async () => {
|
test("single step shows meta and content, and includes tools", async () => {
|
||||||
const onlyHash = "01HASHSINGLESTEP0000000001";
|
const onlyHash = "01HASHSINGLESTEP0000000001";
|
||||||
const ctx: AgentContext = {
|
const ctx: AgentContext = {
|
||||||
start: startTask("user task"),
|
start: startTask("user task"),
|
||||||
@@ -42,6 +42,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
{
|
{
|
||||||
role: "coder",
|
role: "coder",
|
||||||
contentHash: onlyHash,
|
contentHash: onlyHash,
|
||||||
|
content: "Here is my implementation of the feature.",
|
||||||
meta: { files: ["a.ts"] },
|
meta: { files: ["a.ts"] },
|
||||||
refs: [onlyHash],
|
refs: [onlyHash],
|
||||||
timestamp: 2,
|
timestamp: 2,
|
||||||
@@ -52,13 +53,39 @@ describe("buildAgentPrompt", () => {
|
|||||||
expect(text).toContain("## Task");
|
expect(text).toContain("## Task");
|
||||||
expect(text).toContain("user task");
|
expect(text).toContain("user task");
|
||||||
expect(text).toContain("## Step: coder");
|
expect(text).toContain("## Step: coder");
|
||||||
expect(text).toContain(`ContentHash: ${onlyHash}`);
|
|
||||||
expect(text).toContain('Meta: {"files":["a.ts"]}');
|
expect(text).toContain('Meta: {"files":["a.ts"]}');
|
||||||
|
expect(text).toContain("<output>");
|
||||||
|
expect(text).toContain("Here is my implementation of the feature.");
|
||||||
|
expect(text).toContain("</output>");
|
||||||
expect(text).toContain("## Tools");
|
expect(text).toContain("## Tools");
|
||||||
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
|
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("two or more steps: previous steps are meta-only; latest step includes hash", async () => {
|
test("single step with null content omits output tag", async () => {
|
||||||
|
const onlyHash = "01HASHSINGLESTEP0000000001";
|
||||||
|
const ctx: AgentContext = {
|
||||||
|
start: startTask("user task"),
|
||||||
|
depth: 0,
|
||||||
|
bundleHash: "TESTHASH00001",
|
||||||
|
threadId: "01TEST000000000000000000TR",
|
||||||
|
currentRole: { name: "coder", systemPrompt: "Be helpful." },
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
role: "coder",
|
||||||
|
contentHash: onlyHash,
|
||||||
|
content: null,
|
||||||
|
meta: { files: ["a.ts"] },
|
||||||
|
refs: [onlyHash],
|
||||||
|
timestamp: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const text = await buildAgentPrompt(ctx);
|
||||||
|
expect(text).not.toContain("<output>");
|
||||||
|
expect(text).toContain('Meta: {"files":["a.ts"]}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two or more steps: previous steps are meta-only; latest step includes content", async () => {
|
||||||
const plannerHash = "01HASHPLANNER0000000000001";
|
const plannerHash = "01HASHPLANNER0000000000001";
|
||||||
const coderHash = "01HASHCODER0000000000000001";
|
const coderHash = "01HASHCODER0000000000000001";
|
||||||
const ctx: AgentContext = {
|
const ctx: AgentContext = {
|
||||||
@@ -71,6 +98,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
{
|
{
|
||||||
role: "planner",
|
role: "planner",
|
||||||
contentHash: plannerHash,
|
contentHash: plannerHash,
|
||||||
|
content: null,
|
||||||
meta: { plan: "short" },
|
meta: { plan: "short" },
|
||||||
refs: [plannerHash],
|
refs: [plannerHash],
|
||||||
timestamp: 2,
|
timestamp: 2,
|
||||||
@@ -78,6 +106,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
{
|
{
|
||||||
role: "coder",
|
role: "coder",
|
||||||
contentHash: coderHash,
|
contentHash: coderHash,
|
||||||
|
content: "I reviewed the code and found 4 lint issues:\n1. Missing semicolon on line 42\n2. Unused import on line 3",
|
||||||
meta: { done: true },
|
meta: { done: true },
|
||||||
refs: [coderHash],
|
refs: [coderHash],
|
||||||
timestamp: 3,
|
timestamp: 3,
|
||||||
@@ -90,10 +119,11 @@ describe("buildAgentPrompt", () => {
|
|||||||
expect(text).toContain("### Step 1: planner");
|
expect(text).toContain("### Step 1: planner");
|
||||||
expect(text).toContain('Summary: {"plan":"short"}');
|
expect(text).toContain('Summary: {"plan":"short"}');
|
||||||
expect(text).toContain("## Latest Step: coder");
|
expect(text).toContain("## Latest Step: coder");
|
||||||
expect(text).toContain(`ContentHash: ${coderHash}`);
|
|
||||||
expect(text).toContain('Meta: {"done":true}');
|
expect(text).toContain('Meta: {"done":true}');
|
||||||
|
expect(text).toContain("<output>");
|
||||||
|
expect(text).toContain("I reviewed the code and found 4 lint issues:");
|
||||||
|
expect(text).toContain("</output>");
|
||||||
expect(text).toContain("## Tools");
|
expect(text).toContain("## Tools");
|
||||||
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parentState null omits Parent Context section", async () => {
|
test("parentState null omits Parent Context section", async () => {
|
||||||
@@ -125,7 +155,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
expect(text).toContain(`uncaged-workflow cas get ${parentHash}`);
|
expect(text).toContain(`uncaged-workflow cas get ${parentHash}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("middle steps show meta summary only and latest shows hash", async () => {
|
test("middle steps show meta summary only and latest shows content", async () => {
|
||||||
const ha = "01HASHA00000000000000000001";
|
const ha = "01HASHA00000000000000000001";
|
||||||
const hb = "01HASHB00000000000000000001";
|
const hb = "01HASHB00000000000000000001";
|
||||||
const hc = "01HASHC00000000000000000001";
|
const hc = "01HASHC00000000000000000001";
|
||||||
@@ -139,6 +169,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
{
|
{
|
||||||
role: "a",
|
role: "a",
|
||||||
contentHash: ha,
|
contentHash: ha,
|
||||||
|
content: null,
|
||||||
meta: { n: 1 },
|
meta: { n: 1 },
|
||||||
refs: [ha],
|
refs: [ha],
|
||||||
timestamp: 2,
|
timestamp: 2,
|
||||||
@@ -146,6 +177,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
{
|
{
|
||||||
role: "b",
|
role: "b",
|
||||||
contentHash: hb,
|
contentHash: hb,
|
||||||
|
content: null,
|
||||||
meta: { n: 2 },
|
meta: { n: 2 },
|
||||||
refs: [hb],
|
refs: [hb],
|
||||||
timestamp: 3,
|
timestamp: 3,
|
||||||
@@ -153,6 +185,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
{
|
{
|
||||||
role: "c",
|
role: "c",
|
||||||
contentHash: hc,
|
contentHash: hc,
|
||||||
|
content: "Final output from role c",
|
||||||
meta: { n: 3 },
|
meta: { n: 3 },
|
||||||
refs: [hc],
|
refs: [hc],
|
||||||
timestamp: 4,
|
timestamp: 4,
|
||||||
@@ -162,7 +195,35 @@ describe("buildAgentPrompt", () => {
|
|||||||
const text = await buildAgentPrompt(ctx);
|
const text = await buildAgentPrompt(ctx);
|
||||||
expect(text).toContain('Summary: {"n":1}');
|
expect(text).toContain('Summary: {"n":1}');
|
||||||
expect(text).toContain('Summary: {"n":2}');
|
expect(text).toContain('Summary: {"n":2}');
|
||||||
expect(text).toContain(`ContentHash: ${hc}`);
|
|
||||||
expect(text).toContain("## Latest Step: c");
|
expect(text).toContain("## Latest Step: c");
|
||||||
|
expect(text).toContain("<output>");
|
||||||
|
expect(text).toContain("Final output from role c");
|
||||||
|
expect(text).toContain("</output>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("content is truncated when exceeding quota", async () => {
|
||||||
|
const longContent = "x".repeat(20_000);
|
||||||
|
const hash = "01HASHLONG000000000000000001";
|
||||||
|
const ctx: AgentContext = {
|
||||||
|
start: startTask("task"),
|
||||||
|
depth: 0,
|
||||||
|
bundleHash: "TESTHASH00001",
|
||||||
|
threadId: "01TEST000000000000000000TR",
|
||||||
|
currentRole: { name: "r", systemPrompt: "S" },
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
role: "r",
|
||||||
|
contentHash: hash,
|
||||||
|
content: longContent,
|
||||||
|
meta: {},
|
||||||
|
refs: [],
|
||||||
|
timestamp: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const text = await buildAgentPrompt(ctx);
|
||||||
|
expect(text).toContain("<output>");
|
||||||
|
expect(text).toContain("... (truncated)");
|
||||||
|
expect(text.length).toBeLessThan(20_000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"uwf": "bun packages/cli-workflow/src/cli.ts",
|
||||||
"build": "bunx tsc --build",
|
"build": "bunx tsc --build",
|
||||||
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
|
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
|
||||||
"typecheck": "bunx tsc --build",
|
"typecheck": "bunx tsc --build",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ workflow → thread → step → turn
|
|||||||
- **Workflow** (layer 1): YAML template with roles and routing graph
|
- **Workflow** (layer 1): YAML template with roles and routing graph
|
||||||
- **Thread** (layer 2): Single workflow execution instance
|
- **Thread** (layer 2): Single workflow execution instance
|
||||||
- **Step** (layer 3): One moderator→agent→extract cycle
|
- **Step** (layer 3): One moderator→agent→extract cycle
|
||||||
- **Turn** (layer 4): Agent-internal interactions (use `step read` or CAS to inspect)
|
- **Turn** (layer 4): Agent-internal interactions (use `step show` or CAS to inspect)
|
||||||
|
|
||||||
This package has no library `src/index.ts` — it is consumed as a CLI binary only.
|
This package has no library `src/index.ts` — it is consumed as a CLI binary only.
|
||||||
|
|
||||||
@@ -49,8 +49,10 @@ bun link packages/cli-workflow
|
|||||||
| `uwf thread start <workflow> -p <prompt>` | Create a thread without executing |
|
| `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 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 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 |
|
| `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 stop <thread-id>` | Stop background execution (keep thread active) |
|
||||||
| `uwf thread cancel <thread-id>` | Cancel thread (stop + archive to history) |
|
| `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 -c 3 --agent uwf-builtin
|
||||||
uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV --background
|
uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV --background
|
||||||
uwf thread list --status running
|
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 read 01ARZ3NDEKTSV4RRFFQ69G5FAV --quota 8000
|
||||||
uwf thread stop 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
uwf thread stop 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||||
```
|
```
|
||||||
@@ -72,7 +77,6 @@ uwf thread stop 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
|||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `uwf step list <thread-id>` | List all steps in a thread chronologically |
|
| `uwf step list <thread-id>` | List all steps in a thread chronologically |
|
||||||
| `uwf step show <step-hash>` | Show step metadata and frontmatter |
|
| `uwf step show <step-hash>` | Show step metadata and frontmatter |
|
||||||
| `uwf step read <step-hash> [--before N]` | Read step output as markdown |
|
|
||||||
| `uwf step fork <step-hash>` | Fork a thread from a specific step |
|
| `uwf step fork <step-hash>` | Fork a thread from a specific step |
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@@ -80,7 +84,6 @@ Examples:
|
|||||||
```bash
|
```bash
|
||||||
uwf step list 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
uwf step list 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||||
uwf step show 32GCDE899RRQ3
|
uwf step show 32GCDE899RRQ3
|
||||||
uwf step read 32GCDE899RRQ3 --before 3
|
|
||||||
uwf step fork 32GCDE899RRQ3
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import { resolveHeadHash } from "../commands/shared.js";
|
||||||
|
import { appendThreadHistory, saveThreadsIndex } from "../store.js";
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-resolve-head-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveHeadHash", () => {
|
||||||
|
test("returns head hash from threads.yaml for active thread", async () => {
|
||||||
|
const threadId = "01JTEST0000000000000000001" as ThreadId;
|
||||||
|
const headHash = "active_hash_123" as CasRef;
|
||||||
|
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
|
||||||
|
|
||||||
|
const result = await resolveHeadHash(tmpDir, threadId);
|
||||||
|
|
||||||
|
expect(result).toBe(headHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to history.jsonl when thread not in threads.yaml", async () => {
|
||||||
|
const threadId = "01JTEST0000000000000000002" as ThreadId;
|
||||||
|
const headHash = "completed_hash_456" as CasRef;
|
||||||
|
const workflowHash = "workflow_hash_789" as CasRef;
|
||||||
|
|
||||||
|
// No entry in threads.yaml, only in history.jsonl
|
||||||
|
await saveThreadsIndex(tmpDir, {});
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: headHash,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resolveHeadHash(tmpDir, threadId);
|
||||||
|
|
||||||
|
expect(result).toBe(headHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: Testing the error case requires CLI-level testing because resolveHeadHash
|
||||||
|
// calls fail() which does process.exit(1), terminating the test runner.
|
||||||
|
// The error behavior is tested in integration tests below via CLI invocation.
|
||||||
|
|
||||||
|
test("prioritizes active thread over history when thread exists in both", async () => {
|
||||||
|
const threadId = "01JTEST0000000000000000004" as ThreadId;
|
||||||
|
const activeHash = "active_hash_v2" as CasRef;
|
||||||
|
const historicalHash = "historical_hash_v1" as CasRef;
|
||||||
|
const workflowHash = "workflow_hash_xyz" as CasRef;
|
||||||
|
|
||||||
|
// Thread exists in both locations (should not happen normally, but test the precedence)
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: activeHash });
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: historicalHash,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resolveHeadHash(tmpDir, threadId);
|
||||||
|
|
||||||
|
// Should return the active head, not the historical one
|
||||||
|
expect(result).toBe(activeHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("finds thread from multiple history entries", async () => {
|
||||||
|
const threadId1 = "01JTEST0000000000000000005" as ThreadId;
|
||||||
|
const threadId2 = "01JTEST0000000000000000006" as ThreadId;
|
||||||
|
const threadId3 = "01JTEST0000000000000000007" as ThreadId;
|
||||||
|
const hash1 = "hash_thread1" as CasRef;
|
||||||
|
const hash2 = "hash_thread2" as CasRef;
|
||||||
|
const hash3 = "hash_thread3" as CasRef;
|
||||||
|
const workflowHash = "workflow_hash_abc" as CasRef;
|
||||||
|
|
||||||
|
await saveThreadsIndex(tmpDir, {});
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId1,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: hash1,
|
||||||
|
completedAt: Date.now() - 2000,
|
||||||
|
});
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId2,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: hash2,
|
||||||
|
completedAt: Date.now() - 1000,
|
||||||
|
});
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId3,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: hash3,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resolveHeadHash(tmpDir, threadId2);
|
||||||
|
|
||||||
|
expect(result).toBe(hash2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test: Issue #474 - tea pr create fails in git worktree directories
|
||||||
|
*
|
||||||
|
* This test verifies that the solve-issue workflow's committer role
|
||||||
|
* includes the --repo flag when running tea pr create, which fixes
|
||||||
|
* the "path segment [0] is empty" error in worktree directories.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("solve-issue workflow: tea pr create worktree fix", () => {
|
||||||
|
// Navigate up from packages/cli-workflow to repo root
|
||||||
|
const workflowPath = join(process.cwd(), "..", "..", ".workflows", "solve-issue.yaml");
|
||||||
|
|
||||||
|
test("committer procedure should include --repo flag in tea pr create command", async () => {
|
||||||
|
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||||
|
const workflow = parse(yamlContent) as WorkflowPayload;
|
||||||
|
|
||||||
|
expect(workflow.roles.committer).toBeDefined();
|
||||||
|
const committerProcedure = workflow.roles.committer?.procedure;
|
||||||
|
expect(committerProcedure).toBeDefined();
|
||||||
|
|
||||||
|
// Verify the procedure includes tea pr create with --repo flag
|
||||||
|
expect(committerProcedure).toContain("tea pr create");
|
||||||
|
expect(committerProcedure).toContain("--repo");
|
||||||
|
|
||||||
|
// Verify the --repo flag appears before or together with tea pr create
|
||||||
|
// This ensures the command is: tea pr create --repo <owner/repo> ...
|
||||||
|
const teaPrCreateMatch = committerProcedure?.match(/tea pr create[^\n]*/);
|
||||||
|
expect(teaPrCreateMatch).not.toBeNull();
|
||||||
|
|
||||||
|
if (teaPrCreateMatch) {
|
||||||
|
const teaCommandLine = teaPrCreateMatch[0];
|
||||||
|
expect(teaCommandLine).toContain("--repo");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("committer procedure should mention repo extraction from git remote", async () => {
|
||||||
|
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||||
|
const workflow = parse(yamlContent) as WorkflowPayload;
|
||||||
|
|
||||||
|
const committerProcedure = workflow.roles.committer?.procedure;
|
||||||
|
expect(committerProcedure).toBeDefined();
|
||||||
|
|
||||||
|
// Verify the procedure mentions extracting repo info from git remote
|
||||||
|
// This ensures fallback logic is documented
|
||||||
|
expect(committerProcedure).toMatch(/git remote/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("committer procedure should include error handling for tea failures", async () => {
|
||||||
|
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||||
|
const workflow = parse(yamlContent) as WorkflowPayload;
|
||||||
|
|
||||||
|
const committerProcedure = workflow.roles.committer?.procedure;
|
||||||
|
expect(committerProcedure).toBeDefined();
|
||||||
|
|
||||||
|
// Verify the procedure includes error handling guidance
|
||||||
|
// This ensures we capture failures and provide actionable output
|
||||||
|
expect(committerProcedure).toMatch(/error|fail/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workflow should be parseable as valid WorkflowPayload", async () => {
|
||||||
|
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||||
|
const workflow = parse(yamlContent) as WorkflowPayload;
|
||||||
|
|
||||||
|
// 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
|
||||||
|
expect(workflow.roles.committer).toBeDefined();
|
||||||
|
expect(workflow.roles.committer?.description).toBeDefined();
|
||||||
|
expect(workflow.roles.committer?.goal).toBeDefined();
|
||||||
|
expect(workflow.roles.committer?.procedure).toBeDefined();
|
||||||
|
expect(workflow.roles.committer?.output).toBeDefined();
|
||||||
|
expect(workflow.roles.committer?.frontmatter).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("committer frontmatter schema should require success field", async () => {
|
||||||
|
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||||
|
// Parse as any to access the raw YAML structure (frontmatter is inline JSON Schema in YAML)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const workflow = parse(yamlContent) as any;
|
||||||
|
|
||||||
|
const frontmatter = workflow.roles.committer?.frontmatter;
|
||||||
|
expect(frontmatter).toBeDefined();
|
||||||
|
expect(frontmatter?.type).toBe("object");
|
||||||
|
expect(frontmatter?.properties?.success).toBeDefined();
|
||||||
|
expect(frontmatter?.properties?.success?.type).toBe("boolean");
|
||||||
|
expect(frontmatter?.required).toContain("success");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,550 @@
|
|||||||
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||||
|
import { extractUlidTimestamp, generateUlid } from "@uncaged/workflow-util";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import { createMarker, deleteMarker } from "../background/index.js";
|
||||||
|
import { cmdThreadList } from "../commands/thread.js";
|
||||||
|
import { parseTimeInput } from "../commands/thread-time-parser.js";
|
||||||
|
import type { UwfStore } from "../store.js";
|
||||||
|
import { appendThreadHistory, createUwfStore, saveThreadsIndex } from "../store.js";
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
|
||||||
|
const casDir = join(storageRoot, "cas");
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
return createUwfStore(storageRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTestWorkflow(uwf: UwfStore): Promise<CasRef> {
|
||||||
|
const workflowPayload = {
|
||||||
|
name: "test-workflow",
|
||||||
|
roles: {
|
||||||
|
role1: {
|
||||||
|
goal: "test goal",
|
||||||
|
outputSchema: { type: "object" as const, properties: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
graph: { start: "role1" },
|
||||||
|
conditions: {},
|
||||||
|
};
|
||||||
|
return await uwf.store.put(uwf.schemas.workflow, workflowPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTestThread(
|
||||||
|
uwf: UwfStore,
|
||||||
|
storageRoot: string,
|
||||||
|
workflowHash: CasRef,
|
||||||
|
timestamp: number,
|
||||||
|
): Promise<ThreadId> {
|
||||||
|
const threadId = generateUlid(timestamp) as ThreadId;
|
||||||
|
const startPayload = {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "test prompt",
|
||||||
|
};
|
||||||
|
const headHash = await uwf.store.put(uwf.schemas.startNode, startPayload);
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
|
||||||
|
index[threadId] = headHash;
|
||||||
|
await saveThreadsIndex(storageRoot, index);
|
||||||
|
return threadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markThreadRunning(storageRoot: string, threadId: ThreadId, workflow: CasRef) {
|
||||||
|
await createMarker(storageRoot, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow,
|
||||||
|
pid: process.pid, // Use current process PID so isPidAlive returns true
|
||||||
|
startedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeThread(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: ThreadId,
|
||||||
|
workflowHash: CasRef,
|
||||||
|
headHash: CasRef,
|
||||||
|
) {
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
|
||||||
|
delete index[threadId];
|
||||||
|
await saveThreadsIndex(storageRoot, index);
|
||||||
|
await appendThreadHistory(storageRoot, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: headHash,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── test setup ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = await mkdtemp(join(tmpdir(), "thread-list-filters-test-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── status filter tests ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("cmdThreadList status filter", () => {
|
||||||
|
test("should return idle and running threads when status=active", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
|
||||||
|
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
|
||||||
|
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
|
||||||
|
|
||||||
|
await markThreadRunning(tmpDir, thread2, workflowHash);
|
||||||
|
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||||
|
const thread3Head = index[thread3];
|
||||||
|
if (thread3Head === undefined) throw new Error("thread3 head not found");
|
||||||
|
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, ["idle", "running"], null, null, null, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread2].sort());
|
||||||
|
|
||||||
|
// Clean up marker after test
|
||||||
|
await deleteMarker(tmpDir, thread2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should support comma-separated status values", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
|
||||||
|
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
|
||||||
|
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
|
||||||
|
|
||||||
|
await markThreadRunning(tmpDir, thread2, workflowHash);
|
||||||
|
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||||
|
const thread3Head = index[thread3];
|
||||||
|
if (thread3Head === undefined) throw new Error("thread3 head not found");
|
||||||
|
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, ["idle", "completed"], null, null, null, null);
|
||||||
|
|
||||||
|
// Clean up marker
|
||||||
|
await deleteMarker(tmpDir, thread2);
|
||||||
|
|
||||||
|
// thread2 is running (not idle), so should not be included
|
||||||
|
// Expected: thread1 (idle) and thread3 (completed)
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread3].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should support single status filter (backward compat)", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const _thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
|
||||||
|
const _thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
|
||||||
|
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
|
||||||
|
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||||
|
const thread3Head = index[thread3];
|
||||||
|
if (thread3Head === undefined) throw new Error("thread3 head not found");
|
||||||
|
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, ["completed"], null, null, null, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.thread).toBe(thread3);
|
||||||
|
expect(result[0]?.status).toBe("completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return all threads when no status filter provided", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
|
||||||
|
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
|
||||||
|
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
|
||||||
|
|
||||||
|
await markThreadRunning(tmpDir, thread2, workflowHash);
|
||||||
|
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||||
|
const thread3Head = index[thread3];
|
||||||
|
if (thread3Head === undefined) throw new Error("thread3 head not found");
|
||||||
|
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, null, null, null, null, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread2, thread3].sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── time range filtering tests ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("cmdThreadList time filters", () => {
|
||||||
|
test("should filter threads created after given timestamp", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const ts1 = Date.UTC(2026, 4, 20, 0, 0, 0);
|
||||||
|
const ts2 = Date.UTC(2026, 4, 21, 0, 0, 0);
|
||||||
|
const ts3 = Date.UTC(2026, 4, 22, 0, 0, 0);
|
||||||
|
|
||||||
|
const _threadA = await createTestThread(uwf, tmpDir, workflowHash, ts1);
|
||||||
|
const threadB = await createTestThread(uwf, tmpDir, workflowHash, ts2);
|
||||||
|
const threadC = await createTestThread(uwf, tmpDir, workflowHash, ts3);
|
||||||
|
|
||||||
|
// Use a timestamp slightly before ts2 to include threadB
|
||||||
|
const afterMs = Date.UTC(2026, 4, 20, 12, 0, 0);
|
||||||
|
const result = await cmdThreadList(tmpDir, null, afterMs, null, null, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((r) => r.thread).sort()).toEqual([threadB, threadC].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should filter threads created before given timestamp", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const ts1 = Date.UTC(2026, 4, 20, 0, 0, 0);
|
||||||
|
const ts2 = Date.UTC(2026, 4, 21, 0, 0, 0);
|
||||||
|
const ts3 = Date.UTC(2026, 4, 22, 0, 0, 0);
|
||||||
|
|
||||||
|
const threadA = await createTestThread(uwf, tmpDir, workflowHash, ts1);
|
||||||
|
const threadB = await createTestThread(uwf, tmpDir, workflowHash, ts2);
|
||||||
|
const _threadC = await createTestThread(uwf, tmpDir, workflowHash, ts3);
|
||||||
|
|
||||||
|
const beforeMs = Date.UTC(2026, 4, 22, 0, 0, 0);
|
||||||
|
const result = await cmdThreadList(tmpDir, null, null, beforeMs, null, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((r) => r.thread).sort()).toEqual([threadA, threadB].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should support both after and before filters (time range)", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const ts1 = Date.UTC(2026, 4, 20, 0, 0, 0);
|
||||||
|
const ts2 = Date.UTC(2026, 4, 21, 0, 0, 0);
|
||||||
|
const ts3 = Date.UTC(2026, 4, 22, 0, 0, 0);
|
||||||
|
|
||||||
|
const _threadA = await createTestThread(uwf, tmpDir, workflowHash, ts1);
|
||||||
|
const threadB = await createTestThread(uwf, tmpDir, workflowHash, ts2);
|
||||||
|
const _threadC = await createTestThread(uwf, tmpDir, workflowHash, ts3);
|
||||||
|
|
||||||
|
const afterMs = Date.UTC(2026, 4, 20, 12, 0, 0);
|
||||||
|
const beforeMs = Date.UTC(2026, 4, 22, 0, 0, 0);
|
||||||
|
const result = await cmdThreadList(tmpDir, null, afterMs, beforeMs, null, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.thread).toBe(threadB);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── pagination tests ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("cmdThreadList pagination", () => {
|
||||||
|
test("should limit results with --take", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const threads: ThreadId[] = [];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
threads.push(await createTestThread(uwf, tmpDir, workflowHash, Date.now() - i * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, null, null, null, null, 5);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should skip first N threads with --skip", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const threads: ThreadId[] = [];
|
||||||
|
// Create threads in chronological order, but they'll be sorted newest first
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
threads.push(await createTestThread(uwf, tmpDir, workflowHash, Date.now() + i * 100));
|
||||||
|
// Small delay to ensure distinct timestamps
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, null, null, null, 3, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(7);
|
||||||
|
// The 3 newest threads should be skipped, so we should get the 7 oldest
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should support skip + take for pagination", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const threads: ThreadId[] = [];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
threads.push(await createTestThread(uwf, tmpDir, workflowHash, Date.now() + i * 100));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, null, null, null, 5, 3);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
// Should skip first 5 (newest), then take 3
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle take > available threads", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const _thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
|
||||||
|
const _thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
|
||||||
|
const _thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, null, null, null, null, 10);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return empty array when skip >= thread count", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
|
||||||
|
await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
|
||||||
|
await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, null, null, null, 5, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── combined filters tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("combined filters", () => {
|
||||||
|
test("should combine status and time range filters", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const ts1 = Date.UTC(2026, 4, 20, 0, 0, 0);
|
||||||
|
const ts2 = Date.UTC(2026, 4, 21, 0, 0, 0);
|
||||||
|
const ts3 = Date.UTC(2026, 4, 22, 0, 0, 0);
|
||||||
|
const ts4 = Date.UTC(2026, 4, 23, 0, 0, 0);
|
||||||
|
|
||||||
|
const _thread1 = await createTestThread(uwf, tmpDir, workflowHash, ts1);
|
||||||
|
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, ts2);
|
||||||
|
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, ts3);
|
||||||
|
const thread4 = await createTestThread(uwf, tmpDir, workflowHash, ts4);
|
||||||
|
|
||||||
|
await markThreadRunning(tmpDir, thread2, workflowHash);
|
||||||
|
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||||
|
const thread3Head = index[thread3];
|
||||||
|
if (thread3Head === undefined) throw new Error("thread3 head not found");
|
||||||
|
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
|
||||||
|
|
||||||
|
const afterMs = Date.UTC(2026, 4, 20, 12, 0, 0);
|
||||||
|
const result = await cmdThreadList(tmpDir, ["idle"], afterMs, null, null, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.thread).toBe(thread4);
|
||||||
|
expect(result[0]?.status).toBe("idle");
|
||||||
|
|
||||||
|
// Clean up marker
|
||||||
|
await deleteMarker(tmpDir, thread2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should combine status filter and pagination", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const threads: ThreadId[] = [];
|
||||||
|
for (let i = 9; i >= 0; i--) {
|
||||||
|
const thread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() + i * 1000);
|
||||||
|
threads.push(thread);
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||||
|
const headHash = index[thread];
|
||||||
|
if (headHash === undefined) throw new Error("head not found");
|
||||||
|
await completeThread(tmpDir, thread, workflowHash, headHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, ["completed"], null, null, 3, 5);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(5);
|
||||||
|
for (const r of result) {
|
||||||
|
expect(r.status).toBe("completed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should combine time range and pagination", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const threads: ThreadId[] = [];
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const ts = Date.UTC(2026, 4, 1 + i, 0, 0, 0);
|
||||||
|
threads.push(await createTestThread(uwf, tmpDir, workflowHash, ts));
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterMs = Date.UTC(2026, 4, 10, 0, 0, 0);
|
||||||
|
const result = await cmdThreadList(tmpDir, null, afterMs, null, 2, 5);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(5);
|
||||||
|
for (const r of result) {
|
||||||
|
const ts = extractUlidTimestamp(r.thread);
|
||||||
|
expect(ts).not.toBeNull();
|
||||||
|
if (ts !== null) {
|
||||||
|
expect(ts).toBeGreaterThan(afterMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setupMixedStatusThreads(
|
||||||
|
uwf: UwfStore,
|
||||||
|
workflowHash: string,
|
||||||
|
count: number,
|
||||||
|
): Promise<ThreadId[]> {
|
||||||
|
const threads: ThreadId[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const ts = Date.UTC(2026, 4, 10 + i, 0, 0, 0);
|
||||||
|
const thread = await createTestThread(uwf, tmpDir, workflowHash, ts);
|
||||||
|
threads.push(thread);
|
||||||
|
|
||||||
|
if (i % 2 === 0) {
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||||
|
const headHash = index[thread];
|
||||||
|
if (headHash === undefined) throw new Error("head not found");
|
||||||
|
await completeThread(tmpDir, thread, workflowHash, headHash);
|
||||||
|
} else {
|
||||||
|
await markThreadRunning(tmpDir, thread, workflowHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return threads;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupRunningMarkers(threads: ThreadId[]): Promise<void> {
|
||||||
|
for (let i = 0; i < threads.length; i++) {
|
||||||
|
if (i % 2 !== 0) {
|
||||||
|
await deleteMarker(tmpDir, threads[i] as ThreadId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("should combine all filters (status + time + pagination)", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
const threads = await setupMixedStatusThreads(uwf, workflowHash, 15);
|
||||||
|
|
||||||
|
const afterMs = Date.UTC(2026, 4, 14, 12, 0, 0);
|
||||||
|
const beforeMs = Date.UTC(2026, 4, 20, 0, 0, 0);
|
||||||
|
const result = await cmdThreadList(tmpDir, ["idle", "running"], afterMs, beforeMs, 1, 3);
|
||||||
|
|
||||||
|
expect(result.length).toBeLessThanOrEqual(3);
|
||||||
|
for (const r of result) {
|
||||||
|
expect(["idle", "running"]).toContain(r.status);
|
||||||
|
const ts = extractUlidTimestamp(r.thread);
|
||||||
|
if (ts !== null) {
|
||||||
|
expect(ts).toBeGreaterThan(afterMs);
|
||||||
|
expect(ts).toBeLessThan(beforeMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await cleanupRunningMarkers(threads);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── edge cases tests ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
test("should handle empty thread list", async () => {
|
||||||
|
await makeUwfStore(tmpDir);
|
||||||
|
const result = await cmdThreadList(tmpDir, null, null, null, null, null);
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should skip threads with invalid ULID when time filtering", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
|
||||||
|
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
|
||||||
|
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||||
|
index["INVALID_ULID_FORMAT_HERE" as ThreadId] = "01J6HMVRNQKJV2";
|
||||||
|
await saveThreadsIndex(tmpDir, index);
|
||||||
|
|
||||||
|
const afterMs = Date.now() - 3000;
|
||||||
|
const result = await cmdThreadList(tmpDir, null, afterMs, null, null, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread2].sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── time parsing tests ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("relative time parsing", () => {
|
||||||
|
test("should parse '7d' as 7 days ago", () => {
|
||||||
|
const nowMs = Date.UTC(2026, 4, 24, 12, 0, 0);
|
||||||
|
const result = parseTimeInput("7d", nowMs);
|
||||||
|
const expected = Date.UTC(2026, 4, 17, 12, 0, 0);
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should parse '24h' as 24 hours ago", () => {
|
||||||
|
const nowMs = Date.UTC(2026, 4, 24, 12, 0, 0);
|
||||||
|
const result = parseTimeInput("24h", nowMs);
|
||||||
|
const expected = Date.UTC(2026, 4, 23, 12, 0, 0);
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should parse '30m' as 30 minutes ago", () => {
|
||||||
|
const nowMs = Date.UTC(2026, 4, 24, 12, 30, 0);
|
||||||
|
const result = parseTimeInput("30m", nowMs);
|
||||||
|
const expected = Date.UTC(2026, 4, 24, 12, 0, 0);
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should parse '1d' as 1 day ago", () => {
|
||||||
|
const nowMs = Date.UTC(2026, 4, 24, 0, 0, 0);
|
||||||
|
const result = parseTimeInput("1d", nowMs);
|
||||||
|
const expected = Date.UTC(2026, 4, 23, 0, 0, 0);
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ISO date parsing", () => {
|
||||||
|
test("should parse ISO date (YYYY-MM-DD)", () => {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const result = parseTimeInput("2026-05-20", nowMs);
|
||||||
|
const expected = Date.UTC(2026, 4, 20, 0, 0, 0);
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should parse ISO datetime (YYYY-MM-DDTHH:MM:SS)", () => {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const result = parseTimeInput("2026-05-20T14:30:00", nowMs);
|
||||||
|
const expected = Date.parse("2026-05-20T14:30:00");
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should parse ISO datetime with Z suffix", () => {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const result = parseTimeInput("2026-05-20T14:30:00Z", nowMs);
|
||||||
|
const expected = Date.UTC(2026, 4, 20, 14, 30, 0);
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject invalid date formats", () => {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
expect(() => parseTimeInput("not-a-date", nowMs)).toThrow();
|
||||||
|
expect(() => parseTimeInput("2026-13-01", nowMs)).toThrow();
|
||||||
|
expect(() => parseTimeInput("invalid", nowMs)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@ import { bootstrap, putSchema } from "@uncaged/json-cas";
|
|||||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||||
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
import { cmdStepShow } from "../commands/step.js";
|
import { cmdStepList, cmdStepShow } from "../commands/step.js";
|
||||||
import {
|
import {
|
||||||
cmdThreadRead,
|
cmdThreadRead,
|
||||||
extractLastAssistantContent,
|
extractLastAssistantContent,
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from "../commands/thread.js";
|
} from "../commands/thread.js";
|
||||||
import { registerUwfSchemas } from "../schemas.js";
|
import { registerUwfSchemas } from "../schemas.js";
|
||||||
import type { UwfStore } from "../store.js";
|
import type { UwfStore } from "../store.js";
|
||||||
import { saveThreadsIndex } from "../store.js";
|
import { appendThreadHistory, saveThreadsIndex } from "../store.js";
|
||||||
|
|
||||||
// ── schemas used in tests ────────────────────────────────────────────────────
|
// ── schemas used in tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -647,3 +647,383 @@ describe("cmdStepShow (process.exit tests - must be last)", () => {
|
|||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── cmdStepList / cmdStepShow: completed threads ──────────────────────────────
|
||||||
|
|
||||||
|
describe("cmdStepList with completed threads", () => {
|
||||||
|
test("lists steps from active thread", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf-active",
|
||||||
|
description: "desc",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Start prompt",
|
||||||
|
});
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const step1Hash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "role1",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
const step2Hash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: step1Hash,
|
||||||
|
role: "role2",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
const step3Hash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: step2Hash,
|
||||||
|
role: "role3",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000000A1" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: step3Hash });
|
||||||
|
|
||||||
|
const result = await cmdStepList(tmpDir, threadId);
|
||||||
|
|
||||||
|
expect(result.thread).toBe(threadId);
|
||||||
|
expect(result.steps).toHaveLength(4); // start + 3 steps
|
||||||
|
expect(result.steps[1].role).toBe("role1");
|
||||||
|
expect(result.steps[2].role).toBe("role2");
|
||||||
|
expect(result.steps[3].role).toBe("role3");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists steps from completed thread", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf-completed",
|
||||||
|
description: "desc",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Start prompt",
|
||||||
|
});
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const step1Hash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "roleA",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
const step2Hash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: step1Hash,
|
||||||
|
role: "roleB",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000000A2" as ThreadId;
|
||||||
|
// Thread is NOT in threads.yaml (simulating completed thread)
|
||||||
|
await saveThreadsIndex(tmpDir, {});
|
||||||
|
// But it IS in history.jsonl
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: step2Hash,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await cmdStepList(tmpDir, threadId);
|
||||||
|
|
||||||
|
expect(result.thread).toBe(threadId);
|
||||||
|
expect(result.steps).toHaveLength(3); // start + 2 steps
|
||||||
|
expect(result.steps[1].role).toBe("roleA");
|
||||||
|
expect(result.steps[2].role).toBe("roleB");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cmdStepShow with completed threads", () => {
|
||||||
|
test("shows step detail from active thread", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const detailSchemas = await registerDetailSchemas(uwf.store);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf-step-active",
|
||||||
|
description: "desc",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "p",
|
||||||
|
});
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const turnHash = await uwf.store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content: "Active thread response",
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const detailHash = await uwf.store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "sess-active",
|
||||||
|
model: "model-x",
|
||||||
|
duration: 1234,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "coder",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000000B1" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||||
|
|
||||||
|
const result = await cmdStepShow(tmpDir, stepHash);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
sessionId: "sess-active",
|
||||||
|
model: "model-x",
|
||||||
|
duration: 1234,
|
||||||
|
turnCount: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows step detail from completed thread", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const detailSchemas = await registerDetailSchemas(uwf.store);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf-step-completed",
|
||||||
|
description: "desc",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "p",
|
||||||
|
});
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const turnHash = await uwf.store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content: "Completed thread response",
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const detailHash = await uwf.store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "sess-completed",
|
||||||
|
model: "model-y",
|
||||||
|
duration: 5678,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "reviewer",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000000B2" as ThreadId;
|
||||||
|
// Thread is NOT in threads.yaml
|
||||||
|
await saveThreadsIndex(tmpDir, {});
|
||||||
|
// But it IS in history.jsonl
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: stepHash,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await cmdStepShow(tmpDir, stepHash);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
sessionId: "sess-completed",
|
||||||
|
model: "model-y",
|
||||||
|
duration: 5678,
|
||||||
|
turnCount: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cmdThreadRead with completed threads", () => {
|
||||||
|
test("reads completed thread context", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf-read-completed",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
writer: {
|
||||||
|
description: "Write",
|
||||||
|
goal: "You are a writer.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Write content.",
|
||||||
|
output: "Summary.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Write something",
|
||||||
|
});
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "writer",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000000C1" as ThreadId;
|
||||||
|
// Thread is NOT in threads.yaml
|
||||||
|
await saveThreadsIndex(tmpDir, {});
|
||||||
|
// But it IS in history.jsonl
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: stepHash,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||||
|
|
||||||
|
expect(markdown).toContain("writer");
|
||||||
|
expect(markdown).toContain("Write something");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reads completed thread with before filter", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf-read-before",
|
||||||
|
description: "desc",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Do task",
|
||||||
|
});
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const step1Hash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "roleX",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
const step2Hash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: step1Hash,
|
||||||
|
role: "roleY",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
const step3Hash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: step2Hash,
|
||||||
|
role: "roleZ",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000000C2" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, {});
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: step3Hash,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const markdown = await cmdThreadRead(
|
||||||
|
tmpDir,
|
||||||
|
threadId,
|
||||||
|
THREAD_READ_DEFAULT_QUOTA,
|
||||||
|
step2Hash,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should contain step1 (roleX) but not step2 (roleY) or step3 (roleZ)
|
||||||
|
expect(markdown).toContain("roleX");
|
||||||
|
expect(markdown).not.toContain("roleY");
|
||||||
|
expect(markdown).not.toContain("roleZ");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
THREAD_READ_DEFAULT_QUOTA,
|
THREAD_READ_DEFAULT_QUOTA,
|
||||||
type ThreadStatus,
|
type ThreadStatus,
|
||||||
} from "./commands/thread.js";
|
} from "./commands/thread.js";
|
||||||
|
import { parseTimeInput } from "./commands/thread-time-parser.js";
|
||||||
import { cmdWorkflowAdd, cmdWorkflowList, cmdWorkflowShow } from "./commands/workflow.js";
|
import { cmdWorkflowAdd, cmdWorkflowList, cmdWorkflowShow } from "./commands/workflow.js";
|
||||||
import { formatOutput, type OutputFormat } from "./format.js";
|
import { formatOutput, type OutputFormat } from "./format.js";
|
||||||
import { resolveStorageRoot } from "./store.js";
|
import { resolveStorageRoot } from "./store.js";
|
||||||
@@ -168,30 +169,103 @@ thread
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper functions for thread list command parsing
|
||||||
|
function parseStatusFilter(status: string | undefined): ThreadStatus[] | null {
|
||||||
|
if (status === undefined) return null;
|
||||||
|
const raw = status.trim();
|
||||||
|
if (raw === "active") return ["idle", "running"];
|
||||||
|
|
||||||
|
const parts = raw.split(",").map((s) => s.trim());
|
||||||
|
const validStatuses: ThreadStatus[] = ["idle", "running", "completed"];
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!validStatuses.includes(part as ThreadStatus)) {
|
||||||
|
process.stderr.write(
|
||||||
|
`Invalid status: ${part}. Must be one of: idle, running, completed, active\n`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts as ThreadStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeFilters(
|
||||||
|
after: string | undefined,
|
||||||
|
before: string | undefined,
|
||||||
|
nowMs: number,
|
||||||
|
): { afterMs: number | null; beforeMs: number | null } {
|
||||||
|
try {
|
||||||
|
const afterMs = after !== undefined ? parseTimeInput(after, nowMs) : null;
|
||||||
|
const beforeMs = before !== undefined ? parseTimeInput(before, nowMs) : null;
|
||||||
|
return { afterMs, beforeMs };
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
process.stderr.write(`${message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePaginationOptions(
|
||||||
|
skip: string | undefined,
|
||||||
|
take: string | undefined,
|
||||||
|
): { skip: number | null; take: number | null } {
|
||||||
|
let skipVal: number | null = null;
|
||||||
|
let takeVal: number | null = null;
|
||||||
|
|
||||||
|
if (skip !== undefined) {
|
||||||
|
skipVal = Number.parseInt(skip, 10);
|
||||||
|
if (!Number.isInteger(skipVal) || skipVal < 0) {
|
||||||
|
process.stderr.write("--skip must be a non-negative integer\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (take !== undefined) {
|
||||||
|
takeVal = Number.parseInt(take, 10);
|
||||||
|
if (!Number.isInteger(takeVal) || takeVal < 1) {
|
||||||
|
process.stderr.write("--take must be a positive integer\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { skip: skipVal, take: takeVal };
|
||||||
|
}
|
||||||
|
|
||||||
thread
|
thread
|
||||||
.command("list")
|
.command("list")
|
||||||
.description("List threads")
|
.description("List threads")
|
||||||
.option("--status <status>", "Filter by status: idle, running, or completed")
|
.option(
|
||||||
.action((opts: { status: string | undefined }) => {
|
"--status <status>",
|
||||||
const storageRoot = resolveStorageRoot();
|
"Filter by status: idle, running, completed, active (idle+running), or comma-separated values",
|
||||||
runAction(async () => {
|
)
|
||||||
const validStatuses: ThreadStatus[] = ["idle", "running", "completed"];
|
.option("--after <date>", "Filter threads created after this date (ISO or relative like '7d')")
|
||||||
let statusFilter: ThreadStatus | null = null;
|
.option("--before <date>", "Filter threads created before this date (ISO or relative like '7d')")
|
||||||
|
.option("--skip <n>", "Skip first n threads")
|
||||||
|
.option("--take <n>", "Return at most n threads")
|
||||||
|
.action(
|
||||||
|
(opts: {
|
||||||
|
status: string | undefined;
|
||||||
|
after: string | undefined;
|
||||||
|
before: string | undefined;
|
||||||
|
skip: string | undefined;
|
||||||
|
take: string | undefined;
|
||||||
|
}) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const statusFilter = parseStatusFilter(opts.status);
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const { afterMs, beforeMs } = parseTimeFilters(opts.after, opts.before, nowMs);
|
||||||
|
const { skip, take } = parsePaginationOptions(opts.skip, opts.take);
|
||||||
|
|
||||||
if (opts.status !== undefined) {
|
const result = await cmdThreadList(
|
||||||
if (!validStatuses.includes(opts.status as ThreadStatus)) {
|
storageRoot,
|
||||||
process.stderr.write(
|
statusFilter,
|
||||||
`Invalid status: ${opts.status}. Must be one of: idle, running, completed\n`,
|
afterMs,
|
||||||
);
|
beforeMs,
|
||||||
process.exit(1);
|
skip,
|
||||||
}
|
take,
|
||||||
statusFilter = opts.status as ThreadStatus;
|
);
|
||||||
}
|
writeOutput(result);
|
||||||
|
});
|
||||||
const result = await cmdThreadList(storageRoot, statusFilter);
|
},
|
||||||
writeOutput(result);
|
);
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
thread
|
thread
|
||||||
.command("stop")
|
.command("stop")
|
||||||
@@ -475,7 +549,11 @@ cas
|
|||||||
.action((hash: string) => {
|
.action((hash: string) => {
|
||||||
const storageRoot = resolveStorageRoot();
|
const storageRoot = resolveStorageRoot();
|
||||||
runAction(async () => {
|
runAction(async () => {
|
||||||
writeOutput(await cmdCasHas(storageRoot, hash));
|
const result = await cmdCasHas(storageRoot, hash);
|
||||||
|
writeOutput(result);
|
||||||
|
if (!result.exists) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
StepNodePayload,
|
StepNodePayload,
|
||||||
ThreadId,
|
ThreadId,
|
||||||
} from "@uncaged/workflow-protocol";
|
} from "@uncaged/workflow-protocol";
|
||||||
import { loadThreadsIndex, type UwfStore } from "../store.js";
|
import { findThreadInHistory, loadThreadsIndex, type UwfStore } from "../store.js";
|
||||||
|
|
||||||
type ChainState = {
|
type ChainState = {
|
||||||
startHash: CasRef;
|
startHash: CasRef;
|
||||||
@@ -203,11 +203,15 @@ function collectOrderedSteps(
|
|||||||
|
|
||||||
async function resolveHeadHash(storageRoot: string, threadId: ThreadId): Promise<CasRef> {
|
async function resolveHeadHash(storageRoot: string, threadId: ThreadId): Promise<CasRef> {
|
||||||
const index = await loadThreadsIndex(storageRoot);
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
const head = index[threadId];
|
const activeHead = index[threadId];
|
||||||
if (head === undefined) {
|
if (activeHead !== undefined) {
|
||||||
fail(`thread not active: ${threadId}`);
|
return activeHead;
|
||||||
}
|
}
|
||||||
return head;
|
const hist = await findThreadInHistory(storageRoot, threadId);
|
||||||
|
if (hist !== null) {
|
||||||
|
return hist.head;
|
||||||
|
}
|
||||||
|
fail(`thread not found: ${threadId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Parse time input: ISO date (YYYY-MM-DD, YYYY-MM-DDTHH:MM:SS) or relative (7d, 24h, 30m)
|
||||||
|
* Returns Unix timestamp in milliseconds.
|
||||||
|
*/
|
||||||
|
export function parseTimeInput(input: string, nowMs: number): number {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
|
||||||
|
// Relative time: 7d, 24h, 30m
|
||||||
|
const relativeMatch = /^(\d+)(d|h|m)$/.exec(trimmed);
|
||||||
|
if (relativeMatch !== null) {
|
||||||
|
const value = Number.parseInt(relativeMatch[1], 10);
|
||||||
|
const unit = relativeMatch[2];
|
||||||
|
const multiplier = unit === "d" ? 86400000 : unit === "h" ? 3600000 : 60000;
|
||||||
|
return nowMs - value * multiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISO date: try parsing
|
||||||
|
const parsed = Date.parse(trimmed);
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
throw new Error(`invalid time format: ${trimmed} (expected ISO date or relative like '7d')`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ import type {
|
|||||||
AgentConfig,
|
AgentConfig,
|
||||||
CasRef,
|
CasRef,
|
||||||
ModeratorContext,
|
ModeratorContext,
|
||||||
RunningThreadsOutput,
|
|
||||||
StartNodePayload,
|
StartNodePayload,
|
||||||
StartOutput,
|
StartOutput,
|
||||||
StepContext,
|
StepContext,
|
||||||
@@ -17,18 +16,19 @@ import type {
|
|||||||
StepOutput,
|
StepOutput,
|
||||||
ThreadId,
|
ThreadId,
|
||||||
ThreadListItem,
|
ThreadListItem,
|
||||||
|
ThreadsIndex,
|
||||||
WorkflowConfig,
|
WorkflowConfig,
|
||||||
WorkflowPayload,
|
WorkflowPayload,
|
||||||
} from "@uncaged/workflow-protocol";
|
} from "@uncaged/workflow-protocol";
|
||||||
import { createProcessLogger, generateUlid, type ProcessLogger } from "@uncaged/workflow-util";
|
import {
|
||||||
|
createProcessLogger,
|
||||||
|
extractUlidTimestamp,
|
||||||
|
generateUlid,
|
||||||
|
type ProcessLogger,
|
||||||
|
} from "@uncaged/workflow-util";
|
||||||
import { config as loadDotenv } from "dotenv";
|
import { config as loadDotenv } from "dotenv";
|
||||||
import { parse, stringify } from "yaml";
|
import { parse, stringify } from "yaml";
|
||||||
import {
|
import { createMarker, deleteMarker, isThreadRunning } from "../background/index.js";
|
||||||
createMarker,
|
|
||||||
deleteMarker,
|
|
||||||
isThreadRunning,
|
|
||||||
listRunningThreads,
|
|
||||||
} from "../background/index.js";
|
|
||||||
import {
|
import {
|
||||||
appendThreadHistory,
|
appendThreadHistory,
|
||||||
createUwfStore,
|
createUwfStore,
|
||||||
@@ -350,44 +350,115 @@ async function threadListItemFromActive(
|
|||||||
return { thread: threadId, workflow, head, status };
|
return { thread: threadId, workflow, head, status };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cmdThreadList(
|
async function collectActiveThreads(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
statusFilter: ThreadStatus | null,
|
uwf: UwfStore,
|
||||||
|
index: ThreadsIndex,
|
||||||
): Promise<ThreadListItemWithStatus[]> {
|
): Promise<ThreadListItemWithStatus[]> {
|
||||||
const uwf = await createUwfStore(storageRoot);
|
|
||||||
const index = await loadThreadsIndex(storageRoot);
|
|
||||||
const items: ThreadListItemWithStatus[] = [];
|
const items: ThreadListItemWithStatus[] = [];
|
||||||
|
|
||||||
// Add active threads
|
|
||||||
for (const [threadId, head] of Object.entries(index)) {
|
for (const [threadId, head] of Object.entries(index)) {
|
||||||
const item = await threadListItemFromActive(storageRoot, uwf, threadId as ThreadId, head);
|
const item = await threadListItemFromActive(
|
||||||
|
storageRoot,
|
||||||
|
uwf,
|
||||||
|
threadId as ThreadId,
|
||||||
|
head as CasRef,
|
||||||
|
);
|
||||||
if (item !== null) {
|
if (item !== null) {
|
||||||
items.push(item);
|
items.push(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
// Add completed threads if requested
|
async function collectCompletedThreads(
|
||||||
if (statusFilter === "completed" || statusFilter === null) {
|
storageRoot: string,
|
||||||
const activeIds = new Set(items.map((i) => i.thread));
|
activeIds: Set<ThreadId>,
|
||||||
const history = await loadThreadHistory(storageRoot);
|
): Promise<ThreadListItemWithStatus[]> {
|
||||||
for (const entry of history) {
|
const items: ThreadListItemWithStatus[] = [];
|
||||||
if (!activeIds.has(entry.thread)) {
|
const history = await loadThreadHistory(storageRoot);
|
||||||
items.push({
|
const seen = new Set<ThreadId>(); // Deduplication (issue #470)
|
||||||
thread: entry.thread,
|
for (const entry of history) {
|
||||||
workflow: entry.workflow,
|
if (!activeIds.has(entry.thread) && !seen.has(entry.thread)) {
|
||||||
head: entry.head,
|
seen.add(entry.thread);
|
||||||
status: "completed",
|
items.push({
|
||||||
});
|
thread: entry.thread,
|
||||||
}
|
workflow: entry.workflow,
|
||||||
|
head: entry.head,
|
||||||
|
status: "completed",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
// Apply status filter if provided
|
function applyTimeFilters(
|
||||||
if (statusFilter !== null) {
|
items: ThreadListItemWithStatus[],
|
||||||
return items.filter((item) => item.status === statusFilter);
|
afterMs: number | null,
|
||||||
|
beforeMs: number | null,
|
||||||
|
): ThreadListItemWithStatus[] {
|
||||||
|
if (afterMs === null && beforeMs === null) return items;
|
||||||
|
return items.filter((item) => {
|
||||||
|
const ts = extractUlidTimestamp(item.thread);
|
||||||
|
if (ts === null) return false;
|
||||||
|
if (afterMs !== null && ts <= afterMs) return false;
|
||||||
|
if (beforeMs !== null && ts >= beforeMs) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortByNewestFirst(items: ThreadListItemWithStatus[]): ThreadListItemWithStatus[] {
|
||||||
|
return items.sort((a, b) => {
|
||||||
|
const tsA = extractUlidTimestamp(a.thread) ?? 0;
|
||||||
|
const tsB = extractUlidTimestamp(b.thread) ?? 0;
|
||||||
|
return tsB - tsA;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPagination(
|
||||||
|
items: ThreadListItemWithStatus[],
|
||||||
|
skip: number | null,
|
||||||
|
take: number | null,
|
||||||
|
): ThreadListItemWithStatus[] {
|
||||||
|
const skipCount = skip ?? 0;
|
||||||
|
const takeCount = take ?? items.length;
|
||||||
|
return items.slice(skipCount, skipCount + takeCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdThreadList(
|
||||||
|
storageRoot: string,
|
||||||
|
statusFilter: ThreadStatus[] | null,
|
||||||
|
afterMs: number | null,
|
||||||
|
beforeMs: number | null,
|
||||||
|
skip: number | null,
|
||||||
|
take: number | null,
|
||||||
|
): Promise<ThreadListItemWithStatus[]> {
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
|
||||||
|
// Collect active threads
|
||||||
|
let items = await collectActiveThreads(storageRoot, uwf, index);
|
||||||
|
|
||||||
|
// Collect completed threads (if relevant for status filter)
|
||||||
|
const includeCompleted = statusFilter === null || statusFilter.includes("completed");
|
||||||
|
if (includeCompleted) {
|
||||||
|
const activeIds = new Set(items.map((i) => i.thread));
|
||||||
|
const completedItems = await collectCompletedThreads(storageRoot, activeIds);
|
||||||
|
items = items.concat(completedItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
// Apply status filter
|
||||||
|
if (statusFilter !== null) {
|
||||||
|
items = items.filter((item) => statusFilter.includes(item.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply time range filters
|
||||||
|
items = applyTimeFilters(items, afterMs, beforeMs);
|
||||||
|
|
||||||
|
// Sort by timestamp descending (newest first)
|
||||||
|
items = sortByNewestFirst(items);
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
return applyPagination(items, skip, take);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatYaml(value: unknown): string {
|
function formatYaml(value: unknown): string {
|
||||||
@@ -578,6 +649,7 @@ function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorConte
|
|||||||
detail: step.detail,
|
detail: step.detail,
|
||||||
agent: step.agent,
|
agent: step.agent,
|
||||||
edgePrompt: step.edgePrompt ?? "",
|
edgePrompt: step.edgePrompt ?? "",
|
||||||
|
content: null, // Moderator doesn't need content
|
||||||
}));
|
}));
|
||||||
return { start: chain.start, steps };
|
return { start: chain.start, steps };
|
||||||
}
|
}
|
||||||
@@ -1016,8 +1088,3 @@ export async function cmdThreadCancel(
|
|||||||
|
|
||||||
return { thread: threadId, cancelled: true };
|
return { thread: threadId, cancelled: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cmdThreadRunning(storageRoot: string): Promise<RunningThreadsOutput> {
|
|
||||||
const threads = await listRunningThreads(storageRoot);
|
|
||||||
return { threads };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -22,7 +22,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/json-cas": "^0.4.0",
|
"@uncaged/json-cas": "^0.4.0",
|
||||||
"@uncaged/workflow-agent-kit": "workspace:^"
|
"@uncaged/workflow-agent-kit": "workspace:^",
|
||||||
|
"@uncaged/workflow-util": "workspace:^"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function makeCtx(overrides: Partial<AgentContext> = {}): AgentContext {
|
|||||||
graph: {},
|
graph: {},
|
||||||
},
|
},
|
||||||
role: "developer",
|
role: "developer",
|
||||||
start: { prompt: "Fix the bug", workflowHash: "abc123", threadId: "t1" },
|
start: { prompt: "Fix the bug", workflow: "abc123" },
|
||||||
steps: [],
|
steps: [],
|
||||||
store: {} as AgentContext["store"],
|
store: {} as AgentContext["store"],
|
||||||
outputFormatInstruction: "Use YAML frontmatter",
|
outputFormatInstruction: "Use YAML frontmatter",
|
||||||
@@ -55,6 +55,7 @@ describe("buildHermesPrompt", () => {
|
|||||||
agent: "uwf-hermes",
|
agent: "uwf-hermes",
|
||||||
detail: "detail-1",
|
detail: "detail-1",
|
||||||
edgePrompt: "Implement the fix.",
|
edgePrompt: "Implement the fix.",
|
||||||
|
content: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: "reviewer",
|
role: "reviewer",
|
||||||
@@ -62,6 +63,7 @@ describe("buildHermesPrompt", () => {
|
|||||||
agent: "uwf-hermes",
|
agent: "uwf-hermes",
|
||||||
detail: "detail-2",
|
detail: "detail-2",
|
||||||
edgePrompt: "Review the code.",
|
edgePrompt: "Review the code.",
|
||||||
|
content: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -85,6 +87,7 @@ describe("buildHermesPrompt", () => {
|
|||||||
agent: "uwf-hermes",
|
agent: "uwf-hermes",
|
||||||
detail: "detail-1",
|
detail: "detail-1",
|
||||||
edgePrompt: "First attempt.",
|
edgePrompt: "First attempt.",
|
||||||
|
content: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
edgePrompt: "Retry with a fresh approach.",
|
edgePrompt: "Retry with a fresh approach.",
|
||||||
@@ -95,4 +98,90 @@ describe("buildHermesPrompt", () => {
|
|||||||
expect(result).toContain("Retry with a fresh approach.");
|
expect(result).toContain("Retry with a fresh approach.");
|
||||||
expect(result).not.toContain("## What Happened Since Your Last Turn");
|
expect(result).not.toContain("## What Happened Since Your Last Turn");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("first visit includes content from previous steps", () => {
|
||||||
|
const ctx = makeCtx({
|
||||||
|
isFirstVisit: true,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
role: "planner",
|
||||||
|
output: { plan: "hash1" },
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
detail: "detail-1",
|
||||||
|
edgePrompt: "Create the plan.",
|
||||||
|
content: "# Plan\nDetailed plan markdown...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "developer",
|
||||||
|
output: { files: ["app.ts"] },
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
detail: "detail-2",
|
||||||
|
edgePrompt: "Implement the code.",
|
||||||
|
content: "# Implementation\nCode changes...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "reviewer",
|
||||||
|
output: { approved: true },
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
detail: "detail-3",
|
||||||
|
edgePrompt: "Review the work.",
|
||||||
|
content: "# Review\nApproved!",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
role: "committer",
|
||||||
|
edgePrompt: "Commit the reviewed code.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = buildHermesPrompt(ctx);
|
||||||
|
|
||||||
|
expect(result).toContain("Use YAML frontmatter");
|
||||||
|
expect(result).toContain("## Task");
|
||||||
|
expect(result).toContain("Fix the bug");
|
||||||
|
expect(result).toContain("## What Happened Since Your Last Turn");
|
||||||
|
expect(result).toContain("### Step 1: planner");
|
||||||
|
expect(result).toContain("#### Step Content");
|
||||||
|
expect(result).toContain("# Plan");
|
||||||
|
expect(result).toContain("Detailed plan markdown");
|
||||||
|
expect(result).toContain("### Step 2: developer");
|
||||||
|
expect(result).toContain("# Implementation");
|
||||||
|
expect(result).toContain("### Step 3: reviewer");
|
||||||
|
expect(result).toContain("# Review");
|
||||||
|
expect(result).toContain("## Moderator Instruction");
|
||||||
|
expect(result).toContain("Commit the reviewed code.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("re-entry omits content from previous steps", () => {
|
||||||
|
const ctx = makeCtx({
|
||||||
|
isFirstVisit: false,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
role: "developer",
|
||||||
|
output: { files: ["app.ts"] },
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
detail: "detail-1",
|
||||||
|
edgePrompt: "Implement the code.",
|
||||||
|
content: "# Implementation\nCode changes...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "reviewer",
|
||||||
|
output: { approved: false },
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
detail: "detail-2",
|
||||||
|
edgePrompt: "Review the work.",
|
||||||
|
content: "# Review\nNot approved!",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
role: "developer",
|
||||||
|
edgePrompt: "Fix the issues.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = buildHermesPrompt(ctx);
|
||||||
|
|
||||||
|
expect(result).toContain("## What Happened Since Your Last Turn");
|
||||||
|
expect(result).toContain("### Step 2: reviewer");
|
||||||
|
expect(result).toContain(JSON.stringify({ approved: false }));
|
||||||
|
expect(result).not.toContain("#### Step Content");
|
||||||
|
expect(result).not.toContain("# Review");
|
||||||
|
expect(result).not.toContain("Not approved!");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,53 +14,39 @@ import { storeHermesSessionDetail } from "./session-detail.js";
|
|||||||
|
|
||||||
const log = createLogger({ sink: { kind: "stderr" } });
|
const log = createLogger({ sink: { kind: "stderr" } });
|
||||||
|
|
||||||
function buildHistorySummary(steps: AgentContext["steps"]): string {
|
/** Assemble system prompt, task, and prior step outputs for Hermes. */
|
||||||
if (steps.length === 0) {
|
export function buildHermesPrompt(ctx: AgentContext): string {
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildInitialPrompt(ctx: AgentContext): string {
|
|
||||||
const roleDef = ctx.workflow.roles[ctx.role];
|
|
||||||
const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
if (ctx.outputFormatInstruction !== "") {
|
if (ctx.outputFormatInstruction !== "") {
|
||||||
parts.push(ctx.outputFormatInstruction, "");
|
parts.push(ctx.outputFormatInstruction, "");
|
||||||
}
|
}
|
||||||
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
|
|
||||||
const historyBlock = buildHistorySummary(ctx.steps);
|
|
||||||
if (historyBlock !== "") {
|
|
||||||
parts.push("", historyBlock);
|
|
||||||
}
|
|
||||||
parts.push("", "## Moderator Instruction", "", ctx.edgePrompt);
|
|
||||||
return parts.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Assemble system prompt, task, and prior step outputs for Hermes. */
|
|
||||||
export function buildHermesPrompt(ctx: AgentContext): string {
|
|
||||||
if (!ctx.isFirstVisit) {
|
if (!ctx.isFirstVisit) {
|
||||||
const parts: string[] = [];
|
// Re-entry: show only steps since last visit, meta only
|
||||||
if (ctx.outputFormatInstruction !== "") {
|
|
||||||
parts.push(ctx.outputFormatInstruction, "");
|
|
||||||
}
|
|
||||||
parts.push(buildContinuationPrompt(ctx.steps, ctx.role, ctx.edgePrompt));
|
parts.push(buildContinuationPrompt(ctx.steps, ctx.role, ctx.edgePrompt));
|
||||||
return parts.join("\n");
|
return parts.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildInitialPrompt(ctx);
|
// First visit: show initial context with content for recent steps
|
||||||
|
const roleDef = ctx.workflow.roles[ctx.role];
|
||||||
|
const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
|
||||||
|
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
|
||||||
|
|
||||||
|
// Add history with content (last 2-3 steps within quota)
|
||||||
|
if (ctx.steps.length > 0) {
|
||||||
|
parts.push(
|
||||||
|
"",
|
||||||
|
buildContinuationPrompt(ctx.steps, ctx.role, ctx.edgePrompt, {
|
||||||
|
includeContent: true,
|
||||||
|
quota: 32000, // Use THREAD_READ_DEFAULT_QUOTA equivalent
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
parts.push("", "## Moderator Instruction", "", ctx.edgePrompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function storePromptResult(
|
async function storePromptResult(
|
||||||
|
|||||||
@@ -83,9 +83,10 @@ Requires `UWF_EDGE_PROMPT` in the environment (set by `uwf thread step`).
|
|||||||
function buildRolePrompt(role: RoleDefinition): string
|
function buildRolePrompt(role: RoleDefinition): string
|
||||||
function buildOutputFormatInstruction(schema: JSONSchema): string
|
function buildOutputFormatInstruction(schema: JSONSchema): string
|
||||||
function buildContinuationPrompt(
|
function buildContinuationPrompt(
|
||||||
ctx: AgentContext,
|
steps: StepContext[],
|
||||||
priorOutput: string,
|
role: string,
|
||||||
instruction: string,
|
edgePrompt: string,
|
||||||
|
options?: { includeContent?: boolean; quota?: number },
|
||||||
): string
|
): string
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const reviewerStep: StepContext = {
|
|||||||
detail: "2MXBG6PN4A8JR",
|
detail: "2MXBG6PN4A8JR",
|
||||||
agent: "uwf-hermes",
|
agent: "uwf-hermes",
|
||||||
edgePrompt: "Review the developer's work.",
|
edgePrompt: "Review the developer's work.",
|
||||||
|
content: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const developerStep: StepContext = {
|
const developerStep: StepContext = {
|
||||||
@@ -16,6 +17,7 @@ const developerStep: StepContext = {
|
|||||||
detail: "1VPBG9SM5E7WK",
|
detail: "1VPBG9SM5E7WK",
|
||||||
agent: "uwf-hermes",
|
agent: "uwf-hermes",
|
||||||
edgePrompt: "Implement the fix.",
|
edgePrompt: "Implement the fix.",
|
||||||
|
content: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("buildContinuationPrompt", () => {
|
describe("buildContinuationPrompt", () => {
|
||||||
@@ -29,6 +31,7 @@ describe("buildContinuationPrompt", () => {
|
|||||||
detail: "7BQST3VW9F2MA",
|
detail: "7BQST3VW9F2MA",
|
||||||
agent: "uwf-hermes",
|
agent: "uwf-hermes",
|
||||||
edgePrompt: "Revise the plan.",
|
edgePrompt: "Revise the plan.",
|
||||||
|
content: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -70,4 +73,162 @@ describe("buildContinuationPrompt", () => {
|
|||||||
expect(result).toContain("## Moderator Instruction");
|
expect(result).toContain("## Moderator Instruction");
|
||||||
expect(result).toContain("Please revise your work.");
|
expect(result).toContain("Please revise your work.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("includes step content when includeContent option is true", () => {
|
||||||
|
const stepsWithContent: StepContext[] = [
|
||||||
|
{
|
||||||
|
role: "planner",
|
||||||
|
output: { plan: "hash123" },
|
||||||
|
detail: "detail1",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: "# Plan\nDetailed plan markdown...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "developer",
|
||||||
|
output: { filesChanged: ["app.ts"] },
|
||||||
|
detail: "detail2",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: "# Implementation\nCode changes...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "reviewer",
|
||||||
|
output: { approved: false },
|
||||||
|
detail: "detail3",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: "# Review\nFeedback...",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = buildContinuationPrompt(stepsWithContent, "committer", "Commit the changes.", {
|
||||||
|
includeContent: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toContain("## What Happened Since Your Last Turn");
|
||||||
|
expect(result).toContain("### Step 1: planner");
|
||||||
|
expect(result).toContain("#### Step Content");
|
||||||
|
expect(result).toContain("# Plan");
|
||||||
|
expect(result).toContain("Detailed plan markdown");
|
||||||
|
expect(result).toContain("### Step 2: developer");
|
||||||
|
expect(result).toContain("# Implementation");
|
||||||
|
expect(result).toContain("### Step 3: reviewer");
|
||||||
|
expect(result).toContain("# Review");
|
||||||
|
expect(result).toContain("## Moderator Instruction");
|
||||||
|
expect(result).toContain("Commit the changes.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("omits step content when includeContent is false (default)", () => {
|
||||||
|
const stepsWithContent: StepContext[] = [
|
||||||
|
{
|
||||||
|
role: "developer",
|
||||||
|
output: { filesChanged: ["app.ts"] },
|
||||||
|
detail: "detail1",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: "# Implementation\nCode changes...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "reviewer",
|
||||||
|
output: { approved: false },
|
||||||
|
detail: "detail2",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: "# Review\nFeedback...",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = buildContinuationPrompt(stepsWithContent, "developer", "Fix the issues.");
|
||||||
|
|
||||||
|
expect(result).toContain("## What Happened Since Your Last Turn");
|
||||||
|
expect(result).toContain("### Step 2: reviewer");
|
||||||
|
expect(result).toContain(JSON.stringify(stepsWithContent[1]?.output));
|
||||||
|
expect(result).not.toContain("#### Step Content");
|
||||||
|
expect(result).not.toContain("# Review");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("respects quota when includeContent is true", () => {
|
||||||
|
const largeContent = "x".repeat(5000);
|
||||||
|
const stepsWithContent: StepContext[] = [
|
||||||
|
{
|
||||||
|
role: "planner",
|
||||||
|
output: { plan: "hash1" },
|
||||||
|
detail: "detail1",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: largeContent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "developer",
|
||||||
|
output: { files: ["app.ts"] },
|
||||||
|
detail: "detail2",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: largeContent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "reviewer",
|
||||||
|
output: { approved: true },
|
||||||
|
detail: "detail3",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: "# Review\nLooks good!",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = buildContinuationPrompt(stepsWithContent, "committer", "Commit the changes.", {
|
||||||
|
includeContent: true,
|
||||||
|
quota: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should include most recent step(s) within quota
|
||||||
|
expect(result).toContain("### Step 1: reviewer"); // Showing 1 of 3, so step 3 becomes step 1
|
||||||
|
expect(result).toContain("#### Step Content");
|
||||||
|
expect(result).toContain("## Moderator Instruction");
|
||||||
|
expect(result).toContain("Showing 1 of 3 steps (2 omitted due to quota)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles null content gracefully when includeContent is true", () => {
|
||||||
|
const stepsWithMixedContent: StepContext[] = [
|
||||||
|
{
|
||||||
|
role: "planner",
|
||||||
|
output: { plan: "hash1" },
|
||||||
|
detail: "detail1",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: "# Plan\nDetails...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "developer",
|
||||||
|
output: { files: ["app.ts"] },
|
||||||
|
detail: "detail2",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: null, // No content available
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "reviewer",
|
||||||
|
output: { approved: true },
|
||||||
|
detail: "detail3",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: "# Review\nApproved!",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = buildContinuationPrompt(
|
||||||
|
stepsWithMixedContent,
|
||||||
|
"committer",
|
||||||
|
"Commit the changes.",
|
||||||
|
{ includeContent: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toContain("### Step 1: planner");
|
||||||
|
expect(result).toContain("# Plan");
|
||||||
|
expect(result).toContain("### Step 2: developer");
|
||||||
|
// Step 2 should not have content section since content is null
|
||||||
|
expect(result).toContain("### Step 3: reviewer");
|
||||||
|
expect(result).toContain("# Review");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
// We need to test buildHistory indirectly through buildContext
|
||||||
|
// since buildHistory is not exported. For now, we'll test the integration
|
||||||
|
// through the public API in a separate integration test.
|
||||||
|
|
||||||
|
describe("context module - content extraction", () => {
|
||||||
|
test("placeholder - content extraction will be tested via integration tests", () => {
|
||||||
|
// This test is a placeholder. The actual testing of content extraction
|
||||||
|
// will be done through integration tests in build-continuation-prompt.test.ts
|
||||||
|
// where we can verify that StepContext objects have the correct content field.
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,20 @@
|
|||||||
import type { StepContext } from "@uncaged/workflow-protocol";
|
import type { StepContext } from "@uncaged/workflow-protocol";
|
||||||
|
|
||||||
function formatStep(step: StepContext, stepNumber: number): string {
|
function formatStep(step: StepContext, stepNumber: number, includeContent: boolean): string {
|
||||||
return [
|
const lines = [
|
||||||
`### Step ${stepNumber}: ${step.role}`,
|
`### Step ${stepNumber}: ${step.role}`,
|
||||||
`Output: ${JSON.stringify(step.output)}`,
|
`Output: ${JSON.stringify(step.output)}`,
|
||||||
`Agent: ${step.agent}`,
|
`Agent: ${step.agent}`,
|
||||||
].join("\n");
|
];
|
||||||
|
|
||||||
|
if (includeContent && step.content !== null) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("#### Step Content");
|
||||||
|
lines.push("");
|
||||||
|
lines.push(step.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function findLastRoleIndex(steps: StepContext[], role: string): number {
|
function findLastRoleIndex(steps: StepContext[], role: string): number {
|
||||||
@@ -18,6 +27,45 @@ function findLastRoleIndex(steps: StepContext[], role: string): number {
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectStepsWithinQuota(steps: StepContext[], quota: number): StepContext[] {
|
||||||
|
const selected: StepContext[] = [];
|
||||||
|
let totalChars = 0;
|
||||||
|
|
||||||
|
// Work backwards (newest first)
|
||||||
|
for (let i = steps.length - 1; i >= 0; i--) {
|
||||||
|
const step = steps[i];
|
||||||
|
if (step === undefined) continue;
|
||||||
|
|
||||||
|
// Estimate size: meta + content
|
||||||
|
const metaSize = JSON.stringify({
|
||||||
|
role: step.role,
|
||||||
|
output: step.output,
|
||||||
|
agent: step.agent,
|
||||||
|
}).length;
|
||||||
|
const contentSize = step.content?.length ?? 0;
|
||||||
|
const stepSize = metaSize + contentSize;
|
||||||
|
|
||||||
|
if (totalChars + stepSize > quota && selected.length > 0) {
|
||||||
|
// Stop adding steps but keep at least 1
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
selected.unshift(step); // Keep chronological order
|
||||||
|
totalChars += stepSize;
|
||||||
|
|
||||||
|
if (totalChars >= quota) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BuildContinuationPromptOptions = {
|
||||||
|
includeContent?: boolean;
|
||||||
|
quota?: number;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a continuation prompt for a role re-entry.
|
* Build a continuation prompt for a role re-entry.
|
||||||
*
|
*
|
||||||
@@ -28,7 +76,11 @@ export function buildContinuationPrompt(
|
|||||||
steps: StepContext[],
|
steps: StepContext[],
|
||||||
role: string,
|
role: string,
|
||||||
edgePrompt: string,
|
edgePrompt: string,
|
||||||
|
options?: BuildContinuationPromptOptions,
|
||||||
): string {
|
): string {
|
||||||
|
const includeContent = options?.includeContent ?? false;
|
||||||
|
const quota = options?.quota ?? Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
const lastIndex = findLastRoleIndex(steps, role);
|
const lastIndex = findLastRoleIndex(steps, role);
|
||||||
const sinceSteps = lastIndex >= 0 ? steps.slice(lastIndex + 1) : steps;
|
const sinceSteps = lastIndex >= 0 ? steps.slice(lastIndex + 1) : steps;
|
||||||
|
|
||||||
@@ -37,13 +89,25 @@ export function buildContinuationPrompt(
|
|||||||
if (sinceSteps.length > 0) {
|
if (sinceSteps.length > 0) {
|
||||||
parts.push("## What Happened Since Your Last Turn");
|
parts.push("## What Happened Since Your Last Turn");
|
||||||
const baseStepNumber = lastIndex >= 0 ? lastIndex + 2 : 1;
|
const baseStepNumber = lastIndex >= 0 ? lastIndex + 2 : 1;
|
||||||
for (let i = 0; i < sinceSteps.length; i++) {
|
|
||||||
const step = sinceSteps[i];
|
// Select steps within quota (newest-first if includeContent = true)
|
||||||
|
const selectedSteps = includeContent ? selectStepsWithinQuota(sinceSteps, quota) : sinceSteps;
|
||||||
|
|
||||||
|
const skippedCount = sinceSteps.length - selectedSteps.length;
|
||||||
|
if (skippedCount > 0) {
|
||||||
|
parts.push("");
|
||||||
|
parts.push(
|
||||||
|
`_Showing ${selectedSteps.length} of ${sinceSteps.length} steps (${skippedCount} omitted due to quota)_`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < selectedSteps.length; i++) {
|
||||||
|
const step = selectedSteps[i];
|
||||||
if (step === undefined) {
|
if (step === undefined) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
parts.push("");
|
parts.push("");
|
||||||
parts.push(formatStep(step, baseStepNumber + i));
|
parts.push(formatStep(step, baseStepNumber + i, includeContent));
|
||||||
}
|
}
|
||||||
parts.push("");
|
parts.push("");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,38 @@ function expandOutput(store: Store, outputRef: CasRef): unknown {
|
|||||||
return node.payload;
|
return node.payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractStepContent(store: Store, detailRef: CasRef): string | null {
|
||||||
|
const detailNode = store.get(detailRef);
|
||||||
|
if (detailNode === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const detail = detailNode.payload as Record<string, unknown>;
|
||||||
|
const turns = detail.turns;
|
||||||
|
if (!Array.isArray(turns) || turns.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Find last assistant content (same logic as extractLastAssistantContent in cli-workflow)
|
||||||
|
for (let i = turns.length - 1; i >= 0; i--) {
|
||||||
|
const turnRef = turns[i];
|
||||||
|
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 (
|
||||||
|
turn.role === "assistant" &&
|
||||||
|
typeof turn.content === "string" &&
|
||||||
|
turn.content.trim() !== ""
|
||||||
|
) {
|
||||||
|
return turn.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function buildHistory(
|
async function buildHistory(
|
||||||
store: Store,
|
store: Store,
|
||||||
stepsNewestFirst: StepNodePayload[],
|
stepsNewestFirst: StepNodePayload[],
|
||||||
@@ -89,12 +121,14 @@ async function buildHistory(
|
|||||||
const chronological = [...stepsNewestFirst].reverse();
|
const chronological = [...stepsNewestFirst].reverse();
|
||||||
const history: StepContext[] = [];
|
const history: StepContext[] = [];
|
||||||
for (const step of chronological) {
|
for (const step of chronological) {
|
||||||
|
const content = extractStepContent(store, step.detail);
|
||||||
history.push({
|
history.push({
|
||||||
role: step.role,
|
role: step.role,
|
||||||
output: expandOutput(store, step.output),
|
output: expandOutput(store, step.output),
|
||||||
detail: step.detail,
|
detail: step.detail,
|
||||||
agent: step.agent,
|
agent: step.agent,
|
||||||
edgePrompt: step.edgePrompt ?? "",
|
edgePrompt: step.edgePrompt ?? "",
|
||||||
|
content,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return history;
|
return history;
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ type StepNodePayload = StepRecord & {
|
|||||||
### Moderator context
|
### Moderator context
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
type StepContext = Omit<StepRecord, "output"> & { output: unknown };
|
type StepContext = Omit<StepRecord, "output"> & { output: unknown; content: string | null };
|
||||||
|
|
||||||
type ModeratorContext = {
|
type ModeratorContext = {
|
||||||
start: StartNodePayload;
|
start: StartNodePayload;
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export type StepNodePayload = StepRecord & {
|
|||||||
/** JSONata 上下文中的 step — output 被展开 */
|
/** JSONata 上下文中的 step — output 被展开 */
|
||||||
export type StepContext = Omit<StepRecord, "output"> & {
|
export type StepContext = Omit<StepRecord, "output"> & {
|
||||||
output: unknown;
|
output: unknown;
|
||||||
|
content: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ModeratorContext = {
|
export type ModeratorContext = {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { AgentContext } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
|
/** Max characters of step content to include in the prompt. */
|
||||||
|
const CONTENT_QUOTA = 16_000;
|
||||||
|
|
||||||
|
/** Builds the full agent prompt: system instructions plus summarized thread history. */
|
||||||
|
export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(ctx.currentRole.systemPrompt);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("## Task");
|
||||||
|
lines.push(ctx.start.content);
|
||||||
|
|
||||||
|
const { steps } = ctx;
|
||||||
|
if (steps.length === 0) {
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (steps.length === 1) {
|
||||||
|
const s = steps[0];
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`## Step: ${s.role}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`Meta: ${JSON.stringify(s.meta)}`);
|
||||||
|
appendContent(lines, s.content);
|
||||||
|
} else {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("## Previous Steps");
|
||||||
|
for (let i = 0; i < steps.length - 1; i++) {
|
||||||
|
const s = steps[i];
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`### Step ${i + 1}: ${s.role}`);
|
||||||
|
lines.push(`Summary: ${JSON.stringify(s.meta)}`);
|
||||||
|
}
|
||||||
|
const last = steps[steps.length - 1];
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`## Latest Step: ${last.role}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`Meta: ${JSON.stringify(last.meta)}`);
|
||||||
|
appendContent(lines, last.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
lines.push("## Tools");
|
||||||
|
lines.push(
|
||||||
|
`Use \`uncaged-workflow thread ${ctx.threadId}\` to read full details of any previous step.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendContent(lines: string[], content: string | null | undefined): void {
|
||||||
|
if (content === null || content === undefined || content.trim() === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const truncated =
|
||||||
|
content.length > CONTENT_QUOTA
|
||||||
|
? `${content.slice(0, CONTENT_QUOTA)}\n... (truncated)`
|
||||||
|
: content;
|
||||||
|
lines.push("");
|
||||||
|
lines.push("<output>");
|
||||||
|
lines.push(truncated);
|
||||||
|
lines.push("</output>");
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ All exports come from `src/index.ts`.
|
|||||||
```typescript
|
```typescript
|
||||||
function encodeUint64AsCrockford(value: bigint): string
|
function encodeUint64AsCrockford(value: bigint): string
|
||||||
function generateUlid(nowMs: number): string
|
function generateUlid(nowMs: number): string
|
||||||
|
function extractUlidTimestamp(ulid: string): number | null
|
||||||
```
|
```
|
||||||
|
|
||||||
### Logging
|
### Logging
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { extractUlidTimestamp, generateUlid } from "../ulid.js";
|
||||||
|
|
||||||
|
describe("extractUlidTimestamp", () => {
|
||||||
|
it("should extract correct timestamp from ULID", () => {
|
||||||
|
const knownTimestamp = Date.UTC(2026, 4, 20, 0, 0, 0);
|
||||||
|
const ulid = generateUlid(knownTimestamp);
|
||||||
|
const extracted = extractUlidTimestamp(ulid);
|
||||||
|
expect(extracted).toBe(knownTimestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle epoch timestamp (timestamp 0)", () => {
|
||||||
|
const ulid = generateUlid(0);
|
||||||
|
const extracted = extractUlidTimestamp(ulid);
|
||||||
|
expect(extracted).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle recent timestamps", () => {
|
||||||
|
const recentTimestamp = Date.now();
|
||||||
|
const ulid = generateUlid(recentTimestamp);
|
||||||
|
const extracted = extractUlidTimestamp(ulid);
|
||||||
|
expect(extracted).toBe(recentTimestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle max 48-bit timestamp", () => {
|
||||||
|
const maxTimestamp = 2 ** 48 - 1;
|
||||||
|
const ulid = generateUlid(maxTimestamp);
|
||||||
|
const extracted = extractUlidTimestamp(ulid);
|
||||||
|
expect(extracted).toBe(maxTimestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for invalid ULID length", () => {
|
||||||
|
expect(extractUlidTimestamp("")).toBe(null);
|
||||||
|
expect(extractUlidTimestamp("TOOSHORT")).toBe(null);
|
||||||
|
expect(extractUlidTimestamp("TOOLONGAAAAAAAAAAAAAAAAAA")).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for invalid Crockford Base32 characters", () => {
|
||||||
|
expect(extractUlidTimestamp("INVALID!@#$%^&CHARACTERS")).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should extract timestamps from multiple ULIDs correctly", () => {
|
||||||
|
const timestamps = [
|
||||||
|
Date.UTC(2020, 0, 1, 0, 0, 0),
|
||||||
|
Date.UTC(2023, 5, 15, 12, 30, 45),
|
||||||
|
Date.UTC(2026, 11, 31, 23, 59, 59),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const ts of timestamps) {
|
||||||
|
const ulid = generateUlid(ts);
|
||||||
|
const extracted = extractUlidTimestamp(ulid);
|
||||||
|
expect(extracted).toBe(ts);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@ uwf setup --provider <name> --base-url <url> \\
|
|||||||
## Workflow Commands
|
## Workflow Commands
|
||||||
|
|
||||||
\`\`\`
|
\`\`\`
|
||||||
uwf workflow put <file> # register a workflow from YAML file
|
uwf workflow add <file> # register a workflow from YAML file
|
||||||
uwf workflow show <id> # show workflow by name or CAS hash
|
uwf workflow show <id> # show workflow by name or CAS hash
|
||||||
uwf workflow list # list all registered workflows
|
uwf workflow list # list all registered workflows
|
||||||
\`\`\`
|
\`\`\`
|
||||||
@@ -24,20 +24,27 @@ uwf workflow list # list all registered workflows
|
|||||||
|
|
||||||
\`\`\`
|
\`\`\`
|
||||||
uwf thread start <workflow> -p <prompt> # create a thread (no execution)
|
uwf thread start <workflow> -p <prompt> # create a thread (no execution)
|
||||||
uwf thread step <thread-id> # execute one moderator→agent→extract cycle
|
uwf thread exec <thread-id> # execute one moderator→agent→extract cycle
|
||||||
[--agent <cmd>] # override agent command
|
[--agent <cmd>] # override agent command
|
||||||
[-c, --count <number>] # run multiple steps (default: 1)
|
[-c, --count <number>] # run multiple steps (default: 1)
|
||||||
|
[--background] # run in background
|
||||||
uwf thread show <thread-id> # show thread head pointer
|
uwf thread show <thread-id> # show thread head pointer
|
||||||
uwf thread list # list active threads
|
uwf thread list # list threads
|
||||||
[--all] # include archived threads
|
[--status <status>] # filter: idle, running, or completed
|
||||||
uwf thread kill <thread-id> # terminate and archive a thread
|
|
||||||
uwf thread steps <thread-id> # list all steps in a thread
|
|
||||||
uwf thread read <thread-id> # render thread context as markdown
|
uwf thread read <thread-id> # render thread context as markdown
|
||||||
[--quota <chars>] # max output characters (default 32000)
|
[--quota <chars>] # max output characters (default 32000)
|
||||||
[--before <step-hash>] # load steps before this hash (exclusive)
|
[--before <step-hash>] # load steps before this hash (exclusive)
|
||||||
[--start] # include start step in output
|
[--start] # include start step in output
|
||||||
uwf thread fork <step-hash> # fork a thread from a specific step
|
uwf thread stop <thread-id> # stop background execution (keep thread active)
|
||||||
uwf thread step-details <step-hash> # dump full detail node of a step as YAML
|
uwf thread cancel <thread-id> # cancel thread (stop + move to history)
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Step Commands
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
uwf step list <thread-id> # list all steps in a thread
|
||||||
|
uwf step show <step-hash> # show details of a specific step
|
||||||
|
uwf step fork <step-hash> # fork a thread from a specific step
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
## CAS Commands
|
## CAS Commands
|
||||||
@@ -78,10 +85,9 @@ uwf -V, --version # print version
|
|||||||
## Key Concepts
|
## Key Concepts
|
||||||
|
|
||||||
- **Workflow**: YAML definition with roles, conditions, and a routing graph; stored as a CAS node identified by its XXH64 hash.
|
- **Workflow**: YAML definition with roles, conditions, and a routing graph; stored as a CAS node identified by its XXH64 hash.
|
||||||
- **Thread**: A single workflow execution (ULID). State is an immutable CAS chain; active threads are indexed in \`threads.yaml\`.
|
- **Thread**: A running instance of a workflow; points to a chain of CAS step nodes.
|
||||||
- **Step**: One moderator→agent→extract cycle. Run \`uwf thread step\` repeatedly until \`$END\`.
|
- **Step**: One moderator→agent→extract cycle; stored as a CAS node with output + detail refs.
|
||||||
- **CAS**: Content-Addressed Storage — all nodes are immutable and identified by hash.
|
- **Turn**: Agent-internal interaction (within a single step); stored per-turn in the detail node.
|
||||||
- **Role**: Named actor with goal, capabilities, procedure, output, and frontmatter schema; the moderator routes between roles.
|
- **CAS**: Content-addressable store; every artifact (workflows, steps, details, turns) is hashed.
|
||||||
- **Edge Prompt**: Required instruction on each graph edge — the moderator's dispatch message to the agent.
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,4 +24,4 @@ export { normalizeRefsField } from "./refs-field.js";
|
|||||||
export { err, ok } from "./result.js";
|
export { err, ok } from "./result.js";
|
||||||
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
|
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
|
||||||
export type { LogFn, Result } from "./types.js";
|
export type { LogFn, Result } from "./types.js";
|
||||||
export { generateUlid } from "./ulid.js";
|
export { extractUlidTimestamp, generateUlid } from "./ulid.js";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { encodeCrockfordBase32Bits } from "./base32.js";
|
import { decodeCrockfordBase32Bits, encodeCrockfordBase32Bits } from "./base32.js";
|
||||||
|
|
||||||
const ULID_TIME_BITS = 48;
|
const ULID_TIME_BITS = 48;
|
||||||
const ULID_RANDOM_BITS = 80;
|
const ULID_RANDOM_BITS = 80;
|
||||||
@@ -26,3 +26,19 @@ export function generateUlid(nowMs: number): string {
|
|||||||
const payload = (time << BigInt(ULID_RANDOM_BITS)) | rand;
|
const payload = (time << BigInt(ULID_RANDOM_BITS)) | rand;
|
||||||
return encodeCrockfordBase32Bits(payload, ULID_TIME_BITS + ULID_RANDOM_BITS);
|
return encodeCrockfordBase32Bits(payload, ULID_TIME_BITS + ULID_RANDOM_BITS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the timestamp (in milliseconds) from a ULID string.
|
||||||
|
* Returns null if the ULID is invalid.
|
||||||
|
*/
|
||||||
|
export function extractUlidTimestamp(ulid: string): number | null {
|
||||||
|
if (ulid.length !== 26) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const timestampPart = ulid.slice(0, 10);
|
||||||
|
const decoded = decodeCrockfordBase32Bits(timestampPart, ULID_TIME_BITS);
|
||||||
|
if (!decoded.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Number(decoded.value);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user