Compare commits

...

20 Commits

Author SHA1 Message Date
xiaomo 4786a247ac Merge pull request 'refactor: migrate test runner from vitest to bun:test' (#602) from refactor/vitest-to-bun-test into main
CI / check (push) Failing after 8m2s
2026-06-02 11:02:12 +00:00
xiaomo 6802aecb2e Merge pull request 'fix(e2e): cross-platform Docker isolation for e2e-walkthrough' (#600) from fix/e2e-walkthrough-cross-platform into main
CI / check (push) Failing after 1m45s
2026-06-02 11:02:05 +00:00
xingyue e65e2aec72 refactor: migrate test runner from vitest to bun:test
CI / check (pull_request) Failing after 8m0s
- Replace vitest with bun:test across all 8 packages (47 test files)
- vi.spyOn → spyOn, vi.restoreAllMocks() → mock.restore() (3 files)
- toHaveBeenCalledOnce → toHaveBeenCalledTimes(1) (bun:test compat)
- Delete all vitest.config.ts files
- Remove vitest from devDependencies
- Add preload.ts for process.exit mock (cli-workflow)
- Fix import ordering (biome check --write)

All tests pass. Closes #601
2026-06-02 18:55:17 +08:00
xingyue 008701ef46 fix(e2e): cross-platform Docker isolation for e2e-walkthrough
CI / check (pull_request) Failing after 1m35s
Problems on macOS:
- `-v $HOME:$HOME` let container's bun install overwrite host bun
  binary (Linux ARM64 replaced macOS ARM64)
- Container couldn't reach host LLM endpoints (localhost != host)
- Hardcoded `~/repos/workflow` path didn't exist on all machines

Fixes:
- Mount source read-only (`-v $(pwd):/workspace:ro`) + copy inside
- Use container-local HOME (/root) to isolate bun/npm installs
- Add `--add-host=host.docker.internal:host-gateway` for Linux compat
- `docker cp` host config + sed localhost→host.docker.internal
- Use `debate.yaml` instead of `solve-issue.yaml` (no $SUSPEND dep)
- Fix cancel test: `--status cancelled` not `--status completed`

Verified: full 6-step walkthrough passes on macOS, host bun intact.
2026-06-02 18:28:59 +08:00
xingyue f6298c73bf fix: add missing reason field to planner insufficient_info frontmatter
CI / check (push) Failing after 1m43s
The $SUSPEND edge for insufficient_info uses {{{reason}}} template
variable, but the frontmatter schema was missing the reason field.
This caused workflow validation to reject the workflow on thread start.

Fixed in all 3 copies: .workflows/, examples/, workflows/
2026-06-02 13:54:01 +08:00
xiaomo fa188ddf21 Merge pull request 'feat: rename skill subcommand to prompt, add usage/setup' (#599) from feat/prompt-subcommand into main
CI / check (push) Failing after 1m37s
2026-06-02 05:46:44 +00:00
xingyue 61ee22f647 feat: rename skill subcommand to prompt, add usage/setup
CI / check (pull_request) Failing after 2m39s
Rename `uwf skill` → `uwf prompt` to align with ocas CLI convention.

Changes:
- `uwf prompt usage` — combined output of all references (for skill installation)
- `uwf prompt setup` — agent-facing setup instructions
- `uwf prompt list` — list available prompt names
- `uwf prompt <name>` — individual reference (user/author/developer/adapter/bootstrap)
- Removed: commands/skill.ts, __tests__/skill.test.ts
- Added: commands/prompt.ts, __tests__/prompt.test.ts

Ref #598
2026-06-02 13:41:46 +08:00
xiaomo dbfed616f8 Merge pull request 'feat: record suspend event as StepNode in CAS chain' (#594) from feat/589-suspend-cas-chain into main
CI / check (push) Failing after 1m28s
2026-06-02 05:13:13 +00:00
xiaomo f493b251db Merge branch 'main' into feat/589-suspend-cas-chain
CI / check (pull_request) Failing after 1m39s
2026-06-02 05:13:04 +00:00
xiaomo b699200adf Merge pull request 'chore: update solve-issue workflow to use $SUSPEND for insufficient_info' (#597) from feat/592-solve-issue-suspend into main
CI / check (push) Failing after 1m21s
2026-06-02 05:12:14 +00:00
xiaomo 7b13e7deb4 Merge pull request 'feat: thread list/show displays suspended state and message' (#596) from feat/591-thread-list-suspended into main
CI / check (push) Failing after 1m52s
2026-06-02 05:12:12 +00:00
xiaomo b1d9eebcf7 Merge pull request 'feat: uwf thread resume command' (#595) from feat/590-thread-resume into main
CI / check (push) Failing after 1m37s
2026-06-02 05:12:10 +00:00
xiaomo 6b201fd73e Merge pull request 'feat: moderator recognizes $SUSPEND as pseudo-role target' (#593) from feat/588-suspend-pseudo-role into main
CI / check (push) Failing after 2m1s
2026-06-02 05:12:08 +00:00
xiaomo f67507bb32 chore: update solve-issue workflow to use $SUSPEND for insufficient_info
CI / check (pull_request) Failing after 1m37s
- .workflows/solve-issue.yaml
- examples/solve-issue.yaml
- workflows/solve-issue.yaml

All planner insufficient_info routes now use $SUSPEND instead of $END.

Closes #592
2026-06-02 04:56:59 +00:00
xiaomo 00f95547d9 Merge branch 'feat/591-thread-list-suspended' into feat/592-solve-issue-suspend 2026-06-02 04:55:21 +00:00
xiaomo f79db334a0 feat: thread list/show displays suspended state and message
CI / check (pull_request) Failing after 1m59s
- thread list: suspended threads show [suspended] marker via statusDisplay
- thread show: displays suspendedRole, suspendMessage, and resume hint
- New ThreadShowOutput type with hint field
- Tests: 3 cases for display formatting

Closes #591
2026-06-02 04:55:08 +00:00
xiaomo 8e7aa3362a feat: uwf thread resume command
CI / check (pull_request) Failing after 10m55s
- New CLI: uwf thread resume <thread-id> [-p "supplement"]
- Validates thread is suspended, reads suspendedRole/suspendMessage
- Executes step as suspendedRole with resume prompt
- Clears suspend metadata on success
- Refactored cmdThreadStepOnce into composable helpers
- Tests: 5 cases including error, idle transition, prompt injection, cycles

Closes #590
2026-06-02 04:47:47 +00:00
xiaomo 10b478640d feat: record suspend event as StepNode in CAS chain
CI / check (pull_request) Failing after 1m46s
- ThreadIndexEntry supports suspendedRole + suspendMessage metadata
- threads.yaml: suspended threads serialize as objects (backward compat)
- cmdThreadStepOnce writes step before marking thread suspended
- StepOutput extended with suspendedRole/suspendMessage fields
- thread show displays suspend message

Closes #589
2026-06-02 04:44:05 +00:00
xiaomo b0ef9c55a9 feat: moderator recognizes $SUSPEND as pseudo-role target
CI / check (pull_request) Failing after 1m42s
- Add GraphPseudoRole type ($END | $SUSPEND) to workflow-protocol
- Add 'suspended' to ThreadStatus
- evaluate() returns EvaluateSuspendResult for $SUSPEND targets
- Thread show/list derive suspended status from moderator evaluation
- validate-semantic treats $SUSPEND like $END (valid target, no outgoing edges)
- Tests: routing to $SUSPEND, mustache rendering, thread status display

Closes #588
2026-06-02 04:39:29 +00:00
xingyue a335471cc7 Merge pull request 'chore: migrate json-cas to ocas' (#586) from chore/migrate-ocas into main
CI / check (push) Failing after 1m10s
2026-06-02 03:07:50 +00:00
89 changed files with 2174 additions and 426 deletions
+28 -12
View File
@@ -8,20 +8,32 @@ roles:
- docker
- shell
procedure: |
1. Start a Docker container with isolated storage:
1. Start a Docker container with isolated storage.
IMPORTANT: Mount the source code READ-ONLY to prevent the container
from overwriting host files (e.g. bun install would replace macOS bun with Linux bun).
Use a container-local HOME so bun/npm installs stay inside the container.
Add host.docker.internal mapping for LLM API access from inside the container.
```
docker run -d --name uwf-e2e-$$ \
-v $HOME:$HOME \
-e HOME=$HOME \
-v "$(pwd):/workspace:ro" \
-e HOME=/root \
-e UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage \
-w ~/repos/workflow \
--add-host=host.docker.internal:host-gateway \
-w /workspace \
node:22-bookworm \
sleep infinity
```
2. Inside the container, install bun, install deps, then `bun link` all packages
so that `uwf`, `uwf-hermes`, `uwf-builtin` are on PATH (from source):
NOTE: Run this from the workflow monorepo root directory.
On macOS Docker Desktop, host.docker.internal is already available;
--add-host ensures it also works on Linux Docker.
2. Inside the container, copy source to a writable location, install bun, install deps,
then `bun link` all packages so that `uwf`, `uwf-hermes`, `uwf-builtin` are on PATH:
```
docker exec uwf-e2e-$$ bash -c '
# Copy source to writable location (mount is read-only)
cp -r /workspace /root/workflow
# Install bun
curl -fsSL https://bun.sh/install | bash
export PATH="$HOME/.bun/bin:$PATH"
@@ -30,7 +42,7 @@ roles:
mkdir -p $UNCAGED_WORKFLOW_STORAGE_ROOT
# Install workspace deps
cd ~/repos/workflow && bun install --frozen-lockfile
cd /root/workflow && bun install
# bun link each package that has a bin entry
cd packages/cli-workflow && bun link && cd ../..
@@ -44,11 +56,15 @@ roles:
docker exec uwf-e2e-$$ bash -c 'export PATH="$HOME/.bun/bin:$PATH" && uwf-hermes --help'
docker exec uwf-e2e-$$ bash -c 'export PATH="$HOME/.bun/bin:$PATH" && uwf-builtin --help'
```
4. Copy host config if it exists:
4. Copy host uwf config into the container's isolated storage.
The host config contains provider credentials and model settings needed for LLM calls.
Also rewrite any localhost URLs to host.docker.internal so the container can reach host services.
```
docker cp ~/.uncaged/workflow/config.yaml uwf-e2e-$$:/tmp/uwf-e2e-storage/config.yaml 2>/dev/null || true
docker exec uwf-e2e-$$ bash -c '
if [ -f $HOME/.uncaged/workflow/config.yaml ]; then
cp $HOME/.uncaged/workflow/config.yaml $UNCAGED_WORKFLOW_STORAGE_ROOT/config.yaml
if [ -f $UNCAGED_WORKFLOW_STORAGE_ROOT/config.yaml ]; then
sed -i "s|localhost|host.docker.internal|g; s|127\.0\.0\.1|host.docker.internal|g" \
$UNCAGED_WORKFLOW_STORAGE_ROOT/config.yaml
fi
'
```
@@ -87,7 +103,7 @@ roles:
3. `uwf config get models.test.name` — verify it returns "test-model"
Workflow registration tests:
4. `uwf workflow add ~/repos/workflow/examples/solve-issue.yaml` — register workflow
4. `uwf workflow add /root/workflow/examples/debate.yaml` — register a workflow (use debate.yaml as it has no $SUSPEND dependency)
5. Verify the output contains a hash
6. `uwf workflow list` — verify non-empty array
7. Capture the workflow name from the list
@@ -197,7 +213,7 @@ roles:
Cancel:
1. Start a second thread: `uwf thread start <workflowName> -p 'E2E cancel test'`
2. Cancel it: `uwf thread cancel <secondThreadId>`
3. Verify it appears in completed list: `uwf thread list --status completed`
3. Verify it appears in cancelled list: `uwf thread list --status cancelled`
Fork:
4. Fork from the first thread's last step: `uwf step fork <lastStepHash>`
+3 -2
View File
@@ -40,7 +40,8 @@ roles:
required: [$status, plan, repoPath, repoRemote]
- properties:
$status: { const: "insufficient_info" }
required: [$status]
reason: { type: string }
required: [$status, reason]
developer:
description: "TDD implementation per test spec"
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
@@ -228,7 +229,7 @@ graph:
$START:
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
planner:
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
insufficient_info: { role: "$SUSPEND", prompt: "信息不足,需要补充:{{{reason}}}" }
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}. Repo remote: {{{repoRemote}}}." }
developer:
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance. Repo remote: {{{repoRemote}}}." }
+3 -2
View File
@@ -64,7 +64,8 @@ roles:
required: [$status, plan, repoPath, branch, worktree]
- properties:
$status: { const: "insufficient_info" }
required: [$status]
reason: { type: string }
required: [$status, reason]
developer:
description: "TDD implementation per test spec"
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
@@ -216,7 +217,7 @@ graph:
$START:
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
planner:
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
insufficient_info: { role: "$SUSPEND", prompt: "信息不足,需要补充:{{{reason}}}" }
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." }
continue: { role: "developer", prompt: "Continue work on existing branch {{{branch}}} at worktree {{{worktree}}}. Implement the revised TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}. Do NOT create a new branch or worktree — cd into the existing worktree and work there." }
developer:
-1
View File
@@ -28,7 +28,6 @@
"@uncaged/workflow-agent-hermes": "workspace:*",
"bun-types": "^1.3.13",
"typescript": "^5.8.3",
"vitest": "^4.1.7",
"yaml": "^2.9.0"
},
"repository": {
+3 -4
View File
@@ -23,15 +23,14 @@
},
"scripts": {
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
"test": "vitest run",
"test:ci": "vitest run"
"test": "bun test src/",
"test:ci": "bun test src/"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/mustache": "^4.2.6",
"vitest": "^4.1.6"
"@types/mustache": "^4.2.6"
},
"repository": {
"type": "git",
@@ -1,3 +1,4 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -5,7 +6,6 @@ import { join } from "node:path";
import { putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { registerUwfSchemas } from "../schemas.js";
import { saveThreadsIndex } from "../store.js";
@@ -1,8 +1,8 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
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;
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
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;
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import {
cmdConfigGet,
cmdConfigList,
@@ -720,7 +720,10 @@ defaultModel: default
describe("no legacy apiKeyEnv references", () => {
test("config.ts has no references to apiKeyEnv", () => {
const configSource = readFileSync(join(__dirname, "..", "..", "src", "commands", "config.ts"), "utf8");
const configSource = readFileSync(
join(__dirname, "..", "..", "src", "commands", "config.ts"),
"utf8",
);
expect(configSource).not.toContain("apiKeyEnv");
});
@@ -1,9 +1,9 @@
import { describe, expect, test } from "bun:test";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { putSchema } from "@ocas/core";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { createMarker, deleteMarker } from "../background/index.js";
import { cmdThreadList, cmdThreadShow, cmdThreadStart } from "../commands/thread.js";
import {
@@ -175,8 +175,9 @@ async function insertStepNode(
): Promise<void> {
const uwf = await createUwfStore(storageRoot);
const index = await loadThreadsIndex(storageRoot);
const head = index[threadId];
if (head === undefined) throw new Error(`thread ${threadId} not in index`);
const headEntry = index[threadId];
if (headEntry === undefined) throw new Error(`thread ${threadId} not in index`);
const head = headEntry.head;
const outputSchemaHash = await putSchema(uwf.store, OUTPUT_SCHEMA);
const outputHash = await uwf.store.put(outputSchemaHash, outputPayload);
@@ -199,7 +200,7 @@ async function insertStepNode(
detail: detailHash,
})) as CasRef;
index[threadId] = stepHash;
index[threadId] = { head: stepHash, suspendedRole: null, suspendMessage: null };
await saveThreadsIndex(storageRoot, index);
}
@@ -280,7 +281,7 @@ describe("currentRole field", () => {
const tid = thread as ThreadId;
const index = await loadThreadsIndex(storageRoot);
const head = index[tid]!;
const head = index[tid]!.head;
delete index[tid];
await saveThreadsIndex(storageRoot, index);
await appendThreadHistory(storageRoot, {
@@ -309,7 +310,7 @@ describe("currentRole field", () => {
const tid = thread as ThreadId;
const index = await loadThreadsIndex(storageRoot);
const head = index[tid]!;
const head = index[tid]!.head;
delete index[tid];
await saveThreadsIndex(storageRoot, index);
await appendThreadHistory(storageRoot, {
@@ -371,7 +372,7 @@ describe("currentRole field", () => {
const comp = await cmdThreadStart(storageRoot, wf, "completed", tmpDir);
const compId = comp.thread as ThreadId;
const index = await loadThreadsIndex(storageRoot);
const compHead = index[compId]!;
const compHead = index[compId]!.head;
delete index[compId];
await saveThreadsIndex(storageRoot, index);
await appendThreadHistory(storageRoot, {
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { parse } from "yaml";
import { createIncludeTag } from "../include.js";
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdLogClean, cmdLogList, cmdLogShow } from "../commands/log.js";
let storageRoot: string;
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import type { Target, WorkflowPayload } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { evaluate } from "../moderator/evaluate.js";
@@ -51,6 +51,49 @@ describe("evaluate", () => {
});
});
test("status-based routing (needs input → $SUSPEND)", () => {
const graph: Record<string, Record<string, Target>> = {
...solveIssueGraph,
reviewer: {
...solveIssueGraph.reviewer,
needs_input: { role: "$SUSPEND", prompt: "Waiting for user input.", location: null },
},
};
const result = evaluate(graph, "reviewer", { $status: "needs_input" });
expect(result).toEqual({
ok: true,
value: {
action: "suspend",
suspendedRole: "reviewer",
prompt: "Waiting for user input.",
},
});
});
test("$SUSPEND prompt template renders mustache variables", () => {
const graph: Record<string, Record<string, Target>> = {
reviewer: {
needs_input: {
role: "$SUSPEND",
prompt: "Please clarify: {{{question}}}",
location: null,
},
},
};
const result = evaluate(graph, "reviewer", {
$status: "needs_input",
question: "Which API endpoint?",
});
expect(result).toEqual({
ok: true,
value: {
action: "suspend",
suspendedRole: "reviewer",
prompt: "Please clarify: Which API endpoint?",
},
});
});
test("missing role in graph → error", () => {
const result = evaluate(solveIssueGraph, "unknown-role", { $status: "_" });
expect(result.ok).toBe(false);
@@ -0,0 +1,7 @@
const originalExit = process.exit;
process.exit = ((code?: number) => {
throw new Error(`process.exit(${code ?? 1})`);
}) as typeof process.exit;
export { originalExit };
@@ -1,21 +1,23 @@
import { describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, test } from "vitest";
const __dirname = dirname(fileURLToPath(import.meta.url));
import {
cmdSkillAdapter,
cmdSkillAuthor,
cmdSkillDeveloper,
cmdSkillList,
cmdSkillUser,
} from "../commands/skill.js";
cmdPromptAdapter,
cmdPromptAuthor,
cmdPromptDeveloper,
cmdPromptList,
cmdPromptSetup,
cmdPromptUsage,
cmdPromptUser,
} from "../commands/prompt.js";
describe("skill commands", () => {
test("skill list returns all skill names", () => {
const result = cmdSkillList();
describe("prompt commands", () => {
test("prompt list returns all prompt names", () => {
const result = cmdPromptList();
expect(result).toBeInstanceOf(Array);
expect(result).toContain("user");
expect(result).toContain("author");
@@ -26,8 +28,8 @@ describe("skill commands", () => {
}
});
test("skill user returns non-empty markdown string", () => {
const result = cmdSkillUser();
test("prompt user returns non-empty markdown string", () => {
const result = cmdPromptUser();
expect(typeof result).toBe("string");
expect(result).toContain("uwf");
expect(result).toContain("thread");
@@ -36,8 +38,8 @@ describe("skill commands", () => {
expect(result.length).toBeGreaterThan(500);
});
test("skill author returns non-empty markdown string", () => {
const result = cmdSkillAuthor();
test("prompt author returns non-empty markdown string", () => {
const result = cmdPromptAuthor();
expect(typeof result).toBe("string");
expect(result).toContain("frontmatter");
expect(result).toContain("graph");
@@ -47,8 +49,8 @@ describe("skill commands", () => {
expect(result.length).toBeGreaterThan(500);
});
test("skill developer returns non-empty markdown string", () => {
const result = cmdSkillDeveloper();
test("prompt developer returns non-empty markdown string", () => {
const result = cmdPromptDeveloper();
expect(typeof result).toBe("string");
expect(result).toContain("Monorepo");
expect(result).toContain("CAS");
@@ -56,8 +58,8 @@ describe("skill commands", () => {
expect(result.length).toBeGreaterThan(500);
});
test("skill adapter returns non-empty markdown string", () => {
const result = cmdSkillAdapter();
test("prompt adapter returns non-empty markdown string", () => {
const result = cmdPromptAdapter();
expect(typeof result).toBe("string");
expect(result).toContain("createAgent");
expect(result).toContain("AgentContext");
@@ -65,13 +67,36 @@ describe("skill commands", () => {
expect(result.length).toBeGreaterThan(500);
});
test("skill help subcommand is suppressed", () => {
const output = execFileSync("bun", ["src/cli.ts", "skill", "--help"], {
test("prompt usage combines all references", () => {
const result = cmdPromptUsage();
expect(typeof result).toBe("string");
expect(result).toContain("User Reference");
expect(result).toContain("Author Reference");
expect(result).toContain("Developer Reference");
expect(result).toContain("Adapter Reference");
expect(result).toContain("---");
expect(result.length).toBeGreaterThan(2000);
});
test("prompt setup returns setup instructions", () => {
const result = cmdPromptSetup();
expect(typeof result).toBe("string");
expect(result).toContain("uwf Skill Setup");
expect(result).toContain("uwf prompt usage");
expect(result).toContain("uwf prompt setup");
expect(result).toContain("SKILL.md");
expect(result).toContain("version");
});
test("prompt help subcommand is suppressed", () => {
const output = execFileSync("bun", ["src/cli.ts", "prompt", "--help"], {
cwd: join(__dirname, "..", ".."),
encoding: "utf-8",
env: { ...process.env, PATH: `/opt/homebrew/bin:${process.env.PATH}` },
});
expect(output).not.toMatch(/help\s+\[command\]/i);
expect(output).toContain("usage");
expect(output).toContain("setup");
expect(output).toContain("user");
expect(output).toContain("author");
expect(output).toContain("developer");
@@ -1,8 +1,8 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
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";
@@ -1,8 +1,8 @@
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
import { readFileSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { parse } from "yaml";
import { _agentNameFromBinary, _printAgentMenu, cmdSetup } from "../commands/setup.js";
@@ -31,7 +31,7 @@ describe("_agentNameFromBinary", () => {
describe("_printAgentMenu", () => {
test("prints known agents with labels", () => {
const logs: string[] = [];
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
spyOn(console, "log").mockImplementation((...args: unknown[]) => {
logs.push(args.join(" "));
});
@@ -40,12 +40,12 @@ describe("_printAgentMenu", () => {
expect(logs.some((l) => l.includes("Hermes"))).toBe(true);
expect(logs.some((l) => l.includes("Claude Code"))).toBe(true);
vi.restoreAllMocks();
mock.restore();
});
test("prints unknown agents with binary name as label", () => {
const logs: string[] = [];
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
spyOn(console, "log").mockImplementation((...args: unknown[]) => {
logs.push(args.join(" "));
});
@@ -53,7 +53,7 @@ describe("_printAgentMenu", () => {
expect(logs.some((l) => l.includes("uwf-custom-agent"))).toBe(true);
vi.restoreAllMocks();
mock.restore();
});
});
@@ -67,7 +67,7 @@ describe("cmdSetup agent configuration", () => {
});
afterEach(async () => {
vi.restoreAllMocks();
mock.restore();
await rm(storageRoot, { recursive: true, force: true });
});
@@ -80,9 +80,7 @@ describe("cmdSetup agent configuration", () => {
});
test("defaults to hermes agent when no agent specified", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
const result = await cmdSetup(baseArgs());
@@ -93,9 +91,7 @@ describe("cmdSetup agent configuration", () => {
});
test("writes specified agent as default", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
const result = await cmdSetup({ ...baseArgs(), agent: "claude-code" });
@@ -106,9 +102,7 @@ describe("cmdSetup agent configuration", () => {
});
test("preserves existing agents when adding new one", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
// First setup with hermes
await cmdSetup(baseArgs());
@@ -122,9 +116,7 @@ describe("cmdSetup agent configuration", () => {
});
test("updates defaultAgent on re-run with different agent", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
await cmdSetup(baseArgs());
const config1 = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
@@ -136,9 +128,7 @@ describe("cmdSetup agent configuration", () => {
});
test("normalizes agent name with uwf- prefix to bare name", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
const result = await cmdSetup({ ...baseArgs(), agent: "uwf-hermes" });
@@ -151,9 +141,7 @@ describe("cmdSetup agent configuration", () => {
});
test("normalizes uwf-claude-code to claude-code", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
const result = await cmdSetup({ ...baseArgs(), agent: "uwf-claude-code" });
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
import { mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
_discoverAgents,
_isBackspace,
@@ -178,7 +178,7 @@ describe("_isBackspace", () => {
describe("_printProviderMenu", () => {
afterEach(() => {
vi.restoreAllMocks();
mock.restore();
});
const providers = [
@@ -188,7 +188,7 @@ describe("_printProviderMenu", () => {
test("prints correct number of lines (one per provider + custom)", () => {
const lines: string[] = [];
vi.spyOn(console, "log").mockImplementation((msg: string) => {
spyOn(console, "log").mockImplementation((msg: string) => {
lines.push(msg);
});
_printProviderMenu(providers);
@@ -198,7 +198,7 @@ describe("_printProviderMenu", () => {
test("custom option number = providers.length + 1", () => {
const lines: string[] = [];
vi.spyOn(console, "log").mockImplementation((msg: string) => {
spyOn(console, "log").mockImplementation((msg: string) => {
lines.push(msg);
});
_printProviderMenu(providers);
@@ -208,7 +208,7 @@ describe("_printProviderMenu", () => {
test("each provider line contains its label and baseUrl", () => {
const lines: string[] = [];
vi.spyOn(console, "log").mockImplementation((msg: string) => {
spyOn(console, "log").mockImplementation((msg: string) => {
lines.push(msg);
});
_printProviderMenu(providers);
@@ -294,12 +294,12 @@ describe("_resolveModelChoice", () => {
describe("_printModelMenu", () => {
afterEach(() => {
vi.restoreAllMocks();
mock.restore();
});
test("prints all models — each model name appears in output", () => {
const output: string[] = [];
vi.spyOn(console, "log").mockImplementation((msg: string) => {
spyOn(console, "log").mockImplementation((msg: string) => {
output.push(msg);
});
const models = ["model-a", "model-b", "model-c"];
@@ -312,7 +312,7 @@ describe("_printModelMenu", () => {
test("single column when termCols is very small", () => {
const output: string[] = [];
vi.spyOn(console, "log").mockImplementation((msg: string) => {
spyOn(console, "log").mockImplementation((msg: string) => {
output.push(msg);
});
_printModelMenu(["a", "b", "c"], 1);
@@ -322,7 +322,7 @@ describe("_printModelMenu", () => {
test("wide terminal fits multiple columns", () => {
const output: string[] = [];
vi.spyOn(console, "log").mockImplementation((msg: string) => {
spyOn(console, "log").mockImplementation((msg: string) => {
output.push(msg);
});
const models = Array.from({ length: 6 }, (_, i) => `m${i}`);
@@ -338,12 +338,12 @@ describe("_printModelMenu", () => {
describe("_printValidationResult", () => {
afterEach(() => {
vi.restoreAllMocks();
mock.restore();
});
test("ok=true prints success message containing '✓'", () => {
const lines: string[] = [];
vi.spyOn(console, "log").mockImplementation((msg: string) => {
spyOn(console, "log").mockImplementation((msg: string) => {
lines.push(msg);
});
_printValidationResult({ ok: true, error: null });
@@ -352,7 +352,7 @@ describe("_printValidationResult", () => {
test("ok=false prints warning message containing '⚠'", () => {
const lines: string[] = [];
vi.spyOn(console, "log").mockImplementation((msg: string) => {
spyOn(console, "log").mockImplementation((msg: string) => {
lines.push(msg);
});
_printValidationResult({ ok: false, error: "HTTP 401" });
@@ -361,7 +361,7 @@ describe("_printValidationResult", () => {
test("ok=false includes the error string in output", () => {
const lines: string[] = [];
vi.spyOn(console, "log").mockImplementation((msg: string) => {
spyOn(console, "log").mockImplementation((msg: string) => {
lines.push(msg);
});
_printValidationResult({ ok: false, error: "HTTP 401" });
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { cmdSetup, validateModel } from "../commands/setup.js";
describe("validateModel", () => {
@@ -10,18 +10,18 @@ describe("validateModel", () => {
const MODEL = "test-model";
afterEach(() => {
vi.restoreAllMocks();
mock.restore();
});
test("success path — returns ok on 200", async () => {
const mockFetch = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
const mockFetch = spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
const result = await validateModel(BASE_URL, API_KEY, MODEL);
expect(result).toEqual({ ok: true, value: undefined });
expect(mockFetch).toHaveBeenCalledOnce();
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, opts] = mockFetch.mock.calls[0]!;
expect(url).toBe(`${BASE_URL}/chat/completions`);
@@ -37,7 +37,7 @@ describe("validateModel", () => {
});
test("HTTP 401 — returns error containing 401", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
spyOn(globalThis, "fetch").mockResolvedValue(
new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }),
);
@@ -50,7 +50,7 @@ describe("validateModel", () => {
});
test("HTTP 404 — returns error containing 404", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
spyOn(globalThis, "fetch").mockResolvedValue(
new Response("Not Found", { status: 404, statusText: "Not Found" }),
);
@@ -64,7 +64,7 @@ describe("validateModel", () => {
test("network timeout — returns error mentioning timeout", async () => {
const err = new DOMException("signal timed out", "AbortError");
vi.spyOn(globalThis, "fetch").mockRejectedValue(err);
spyOn(globalThis, "fetch").mockRejectedValue(err);
const result = await validateModel(BASE_URL, API_KEY, MODEL);
@@ -75,7 +75,7 @@ describe("validateModel", () => {
});
test("network error (DNS/connection) — returns error mentioning connectivity", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed"));
spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed"));
const result = await validateModel(BASE_URL, API_KEY, MODEL);
@@ -86,9 +86,9 @@ describe("validateModel", () => {
});
test("request body correctness", async () => {
const mockFetch = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
const mockFetch = spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
await validateModel(BASE_URL, API_KEY, "my-special-model");
@@ -109,7 +109,7 @@ describe("cmdSetup with validation", () => {
});
afterEach(async () => {
vi.restoreAllMocks();
mock.restore();
await rm(storageRoot, { recursive: true, force: true });
});
@@ -122,9 +122,7 @@ describe("cmdSetup with validation", () => {
});
test("includes validation result on success", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
const result = await cmdSetup(setupArgs());
@@ -134,7 +132,7 @@ describe("cmdSetup with validation", () => {
});
test("includes validation failure — config still saved", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
spyOn(globalThis, "fetch").mockResolvedValue(
new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }),
);
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test";
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";
/**
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test } from "bun:test";
/**
* B-group tests: validate JSON parsing logic used by spawnAgent.
@@ -1,10 +1,10 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import type { CasRef } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdStepRead } from "../commands/step.js";
import { registerUwfSchemas } from "../schemas.js";
@@ -1,10 +1,10 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, type Hash, type JSONSchema, putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import type { CasRef, StepNodePayload } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdStepShow } from "../commands/step.js";
import { formatOutput } from "../format.js";
import { registerUwfSchemas } from "../schemas.js";
@@ -1,3 +1,4 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -5,7 +6,6 @@ import { bootstrap, putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { STEP_NODE_SCHEMA } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdStepList } from "../commands/step.js";
import { cmdThreadRead } from "../commands/thread.js";
import { registerUwfSchemas } from "../schemas.js";
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
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 { createUwfStore, getCasDir, getGlobalCasDir } from "../store.js";
describe("Global CAS directory", () => {
@@ -1,8 +1,8 @@
import { describe, expect, test } from "bun:test";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { appendThreadHistory, loadThreadHistory } from "../store.js";
describe("thread cancel status", () => {
@@ -1,9 +1,10 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
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 { createThreadIndexEntry } 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";
@@ -45,11 +46,15 @@ async function createTestThread(
const startPayload = {
workflow: workflowHash,
prompt: "test prompt",
cwd: storageRoot,
};
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);
// Load existing index and add new thread
const existingIndex = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
existingIndex[threadId] = createThreadIndexEntry(headHash);
await saveThreadsIndex(storageRoot, existingIndex);
return threadId;
}
@@ -106,7 +111,7 @@ describe("cmdThreadList status filter", () => {
await markThreadRunning(tmpDir, thread2, workflowHash);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
const thread3Head = index[thread3];
const thread3Head = index[thread3]!.head;
if (thread3Head === undefined) throw new Error("thread3 head not found");
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
@@ -130,7 +135,7 @@ describe("cmdThreadList status filter", () => {
await markThreadRunning(tmpDir, thread2, workflowHash);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
const thread3Head = index[thread3];
const thread3Head = index[thread3]!.head;
if (thread3Head === undefined) throw new Error("thread3 head not found");
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
@@ -154,7 +159,7 @@ describe("cmdThreadList status filter", () => {
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];
const thread3Head = index[thread3]!.head;
if (thread3Head === undefined) throw new Error("thread3 head not found");
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
@@ -176,7 +181,7 @@ describe("cmdThreadList status filter", () => {
await markThreadRunning(tmpDir, thread2, workflowHash);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
const thread3Head = index[thread3];
const thread3Head = index[thread3]!.head;
if (thread3Head === undefined) throw new Error("thread3 head not found");
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
@@ -348,7 +353,7 @@ describe("combined filters", () => {
await markThreadRunning(tmpDir, thread2, workflowHash);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
const thread3Head = index[thread3];
const thread3Head = index[thread3]!.head;
if (thread3Head === undefined) throw new Error("thread3 head not found");
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
@@ -372,7 +377,7 @@ describe("combined filters", () => {
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];
const headHash = index[thread]!.head;
if (headHash === undefined) throw new Error("head not found");
await completeThread(tmpDir, thread, workflowHash, headHash);
}
@@ -421,7 +426,7 @@ describe("combined filters", () => {
if (i % 2 === 0) {
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
const headHash = index[thread];
const headHash = index[thread]!.head;
if (headHash === undefined) throw new Error("head not found");
await completeThread(tmpDir, thread, workflowHash, headHash);
} else {
@@ -479,7 +484,11 @@ describe("edge cases", () => {
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";
index["INVALID_ULID_FORMAT_HERE" as ThreadId] = {
head: "01J6HMVRNQKJV2",
suspendedRole: null,
suspendMessage: null,
};
await saveThreadsIndex(tmpDir, index);
const afterMs = Date.now() - 3000;
@@ -1,8 +1,8 @@
import { describe, expect, test } from "bun:test";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasRef, StartNodePayload, ThreadId } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { cmdThreadStart } from "../commands/thread.js";
import { createUwfStore } from "../store.js";
@@ -80,7 +80,7 @@ graph:
// Verify StartNode has the cwd field
const uwf = await createUwfStore(storageRoot);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
const headHash = index[result.thread as ThreadId];
const headHash = index[result.thread as ThreadId]!.head;
expect(headHash).toBeDefined();
const startNode = uwf.store.get(headHash as CasRef);
@@ -128,7 +128,7 @@ graph:
const workflowPath = join(tmpDir, "test-location.yaml");
await writeFile(workflowPath, workflowYaml, "utf8");
// Relative path should fail (process.exit is wrapped by vitest)
// Relative path should fail via fail() → process.exit (mocked in test preload)
await expect(
cmdThreadStart(storageRoot, workflowPath, "test", tmpDir, "relative/path"),
).rejects.toThrow();
@@ -175,7 +175,7 @@ graph:
const uwf = await createUwfStore(storageRoot);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
const headHash = index[result.thread as ThreadId];
const headHash = index[result.thread as ThreadId]!.head;
const startNode = uwf.store.get(headHash as CasRef);
const startPayload = startNode?.payload as StartNodePayload;
@@ -1,10 +1,10 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdThreadRead } from "../commands/thread.js";
import { registerUwfSchemas } from "../schemas.js";
import { saveThreadsIndex } from "../store.js";
@@ -1,10 +1,10 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdThreadRead, THREAD_READ_DEFAULT_QUOTA } from "../commands/thread.js";
import { registerUwfSchemas } from "../schemas.js";
import type { UwfStore } from "../store.js";
@@ -0,0 +1,442 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/workflow-protocol";
import { parse } from "yaml";
import { cmdThreadShow } from "../commands/thread.js";
import { registerUwfSchemas } from "../schemas.js";
import { saveThreadsIndex } from "../store.js";
const OUTPUT_SCHEMA = {
type: "object" as const,
properties: {
$status: { type: "string" as const },
question: { type: "string" as const },
},
required: ["$status"],
additionalProperties: false,
};
const THREAD_ID = "01RESUMESTEPTEST0000000" as ThreadId;
const SUSPEND_MESSAGE = "Please clarify: Which API?";
type MockAgentMode = "suspend" | "ok";
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-resume-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
async function setupSuspendedThread(mode: MockAgentMode): Promise<{
casDir: string;
mockAgentPath: string;
promptCapturePath: string;
}> {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const outputSchemaHash = await putSchema(store, OUTPUT_SCHEMA);
const workflowHash = await store.put(schemas.workflow, {
name: "test-resume",
description: "resume command integration test",
roles: {
worker: {
description: "Worker role",
goal: "Work",
capabilities: [],
procedure: "work",
output: "result",
frontmatter: outputSchemaHash,
},
reviewer: {
description: "Reviewer role",
goal: "Review",
capabilities: [],
procedure: "review",
output: "result",
frontmatter: outputSchemaHash,
},
},
graph: {
$START: { _: { role: "worker", prompt: "Start work", location: null } },
worker: {
needs_input: {
role: "$SUSPEND",
prompt: "Please clarify: {{{question}}}",
location: null,
},
ok: { role: "reviewer", prompt: "Review the work", location: null },
},
reviewer: { _: { role: "$END", prompt: "Done", location: null } },
},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test resume task",
cwd: tmpDir,
});
await saveThreadsIndex(tmpDir, { [THREAD_ID]: startHash });
const outputHash = await store.put(outputSchemaHash, {
$status: "needs_input",
question: "Which API?",
});
const detailHash = await store.put(schemas.text, "mock detail");
const startedAtMs = 1716600000000;
const completedAtMs = 1716600001500;
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-mock",
edgePrompt: "Start work",
startedAtMs,
completedAtMs,
cwd: tmpDir,
assembledPrompt: null,
});
await saveThreadsIndex(tmpDir, {
[THREAD_ID]: {
head: stepHash,
suspendedRole: "worker",
suspendMessage: SUSPEND_MESSAGE,
},
});
const promptCapturePath = join(tmpDir, "captured-prompt.txt");
const mockAgentPath = join(tmpDir, "mock-agent.sh");
const frontmatter =
mode === "suspend" ? { $status: "needs_input", question: "Which API?" } : { $status: "ok" };
const adapterJson = JSON.stringify({
stepHash: await store.put(schemas.stepNode, {
start: startHash,
prev: stepHash,
role: "worker",
output: await store.put(outputSchemaHash, frontmatter),
detail: detailHash,
agent: "uwf-mock",
edgePrompt: "resume prompt placeholder",
startedAtMs: completedAtMs + 1,
completedAtMs: completedAtMs + 2,
cwd: tmpDir,
assembledPrompt: null,
}),
detailHash,
role: "worker",
frontmatter,
body: "",
startedAtMs: completedAtMs + 1,
completedAtMs: completedAtMs + 2,
});
await writeFile(
mockAgentPath,
`#!/bin/sh
prompt=""
while [ $# -gt 0 ]; do
if [ "$1" = "--prompt" ]; then
prompt="$2"
shift 2
else
shift
fi
done
printf '%s' "$prompt" > '${promptCapturePath}'
echo '${adapterJson}'
`,
{ mode: 0o755 },
);
const configPath = join(tmpDir, "config.yaml");
await writeFile(
configPath,
`defaultAgent: uwf-hermes\ndefaultModel: test-model\nagentOverrides: null\nagents: {}\nproviders: {}\nmodels: {}\n`,
);
return { casDir, mockAgentPath, promptCapturePath };
}
function runUwf(
args: string[],
casDir: string,
): { stdout: string; stderr: string; status: number } {
const cliPath = join(import.meta.dirname, "..", "cli.js");
try {
const stdout = execFileSync("bun", ["run", cliPath, ...args], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
WORKFLOW_STORAGE_ROOT: tmpDir,
UNCAGED_CAS_DIR: casDir,
},
cwd: tmpDir,
timeout: 30000,
});
return { stdout, stderr: "", status: 0 };
} catch (error) {
const err = error as NodeJS.ErrnoException & {
stdout?: string | Buffer;
stderr?: string | Buffer;
status?: number;
};
return {
stdout: typeof err.stdout === "string" ? err.stdout : (err.stdout?.toString("utf8") ?? ""),
stderr: typeof err.stderr === "string" ? err.stderr : (err.stderr?.toString("utf8") ?? ""),
status: err.status ?? 1,
};
}
}
describe("uwf thread resume", () => {
test("resume non-suspended thread returns error", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "idle-workflow",
description: "idle thread",
roles: {
worker: {
description: "Worker",
goal: "Work",
capabilities: [],
procedure: "work",
output: "result",
frontmatter: await putSchema(store, OUTPUT_SCHEMA),
},
},
graph: {
$START: { _: { role: "worker", prompt: "Start", location: null } },
worker: { _: { role: "$END", prompt: "Done", location: null } },
},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "task",
cwd: tmpDir,
});
await saveThreadsIndex(tmpDir, { [THREAD_ID]: startHash });
const result = runUwf(["thread", "resume", THREAD_ID], casDir);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("thread is not suspended");
});
test("resume suspended thread executes step and becomes idle", async () => {
const originalCasDir = process.env.UNCAGED_CAS_DIR;
const { casDir, mockAgentPath } = await setupSuspendedThread("ok");
process.env.UNCAGED_CAS_DIR = casDir;
try {
const result = runUwf(["thread", "resume", THREAD_ID, "--agent", mockAgentPath], casDir);
expect(result.status).toBe(0);
const cliOutput = JSON.parse(result.stdout.trim());
expect(cliOutput.status).toBe("idle");
expect(cliOutput.currentRole).toBe("reviewer");
expect(cliOutput.suspendedRole).toBeNull();
expect(cliOutput.suspendMessage).toBeNull();
expect(cliOutput.done).toBe(false);
const threadsYaml = await readFile(join(tmpDir, "threads.yaml"), "utf8");
const threadsIndex = parse(threadsYaml) as Record<string, unknown>;
expect(threadsIndex[THREAD_ID]).toBe(cliOutput.head);
const showResult = await cmdThreadShow(tmpDir, THREAD_ID);
expect(showResult.status).toBe("idle");
expect(showResult.suspendedRole).toBeNull();
expect(showResult.suspendMessage).toBeNull();
} finally {
if (originalCasDir === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalCasDir;
}
}
});
test("resume without -p uses suspend message as agent prompt", async () => {
const originalCasDir = process.env.UNCAGED_CAS_DIR;
const { casDir, mockAgentPath, promptCapturePath } = await setupSuspendedThread("ok");
process.env.UNCAGED_CAS_DIR = casDir;
try {
const result = runUwf(["thread", "resume", THREAD_ID, "--agent", mockAgentPath], casDir);
expect(result.status).toBe(0);
const capturedPrompt = await readFile(promptCapturePath, "utf8");
expect(capturedPrompt).toBe(SUSPEND_MESSAGE);
} finally {
if (originalCasDir === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalCasDir;
}
}
});
test("resume with -p appends supplementary info to agent prompt", async () => {
const originalCasDir = process.env.UNCAGED_CAS_DIR;
const { casDir, mockAgentPath, promptCapturePath } = await setupSuspendedThread("ok");
process.env.UNCAGED_CAS_DIR = casDir;
try {
const supplement = "Use the REST API.";
const result = runUwf(
["thread", "resume", THREAD_ID, "-p", supplement, "--agent", mockAgentPath],
casDir,
);
expect(result.status).toBe(0);
const capturedPrompt = await readFile(promptCapturePath, "utf8");
expect(capturedPrompt).toBe(`${SUSPEND_MESSAGE}\n\n${supplement}`);
} finally {
if (originalCasDir === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalCasDir;
}
}
});
test("multiple suspend/resume cycles", async () => {
const originalCasDir = process.env.UNCAGED_CAS_DIR;
const { casDir, mockAgentPath, promptCapturePath } = await setupSuspendedThread("suspend");
process.env.UNCAGED_CAS_DIR = casDir;
try {
const firstResult = runUwf(["thread", "resume", THREAD_ID, "--agent", mockAgentPath], casDir);
expect(firstResult.status).toBe(0);
const firstResume = JSON.parse(firstResult.stdout.trim());
expect(firstResume.status).toBe("suspended");
expect(firstResume.suspendedRole).toBe("worker");
expect(firstResume.suspendMessage).toBe(SUSPEND_MESSAGE);
const threadsAfterFirst = parse(
await readFile(join(tmpDir, "threads.yaml"), "utf8"),
) as Record<string, unknown>;
expect(threadsAfterFirst[THREAD_ID]).toEqual({
head: firstResume.head,
suspendedRole: "worker",
suspendMessage: SUSPEND_MESSAGE,
});
const { mockAgentPath: okMockAgentPath } = await setupOkMockAgent(
casDir,
firstResume.head as CasRef,
);
const secondResult = runUwf(
["thread", "resume", THREAD_ID, "--agent", okMockAgentPath],
casDir,
);
expect(secondResult.status).toBe(0);
const secondResume = JSON.parse(secondResult.stdout.trim());
expect(secondResume.status).toBe("idle");
expect(secondResume.currentRole).toBe("reviewer");
expect(secondResume.suspendedRole).toBeNull();
expect(secondResume.suspendMessage).toBeNull();
const capturedPrompt = await readFile(promptCapturePath, "utf8");
expect(capturedPrompt).toBe(SUSPEND_MESSAGE);
} finally {
if (originalCasDir === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalCasDir;
}
}
});
});
async function setupOkMockAgent(
casDir: string,
prevHead: CasRef,
): Promise<{ mockAgentPath: string }> {
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const outputSchemaHash = await putSchema(store, OUTPUT_SCHEMA);
const prevNode = store.get(prevHead);
if (prevNode === null || prevNode.type !== schemas.stepNode) {
throw new Error(`expected StepNode at ${prevHead}`);
}
const prevPayload = prevNode.payload as StepNodePayload;
const outputHash = await store.put(outputSchemaHash, { $status: "ok" });
const detailHash = await store.put(schemas.text, "ok detail");
const startedAtMs = Date.now();
const completedAtMs = startedAtMs + 1;
const stepHash = await store.put(schemas.stepNode, {
start: prevPayload.start,
prev: prevHead,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-mock",
edgePrompt: "resume",
startedAtMs,
completedAtMs,
cwd: tmpDir,
assembledPrompt: null,
});
const promptCapturePath = join(tmpDir, "captured-prompt.txt");
const mockAgentPath = join(tmpDir, "mock-agent-ok.sh");
const adapterJson = JSON.stringify({
stepHash,
detailHash,
role: "worker",
frontmatter: { $status: "ok" },
body: "",
startedAtMs,
completedAtMs,
});
await writeFile(
mockAgentPath,
`#!/bin/sh
prompt=""
while [ $# -gt 0 ]; do
if [ "$1" = "--prompt" ]; then
prompt="$2"
shift 2
else
shift
fi
done
printf '%s' "$prompt" > '${promptCapturePath}'
echo '${adapterJson}'
`,
{ mode: 0o755 },
);
return { mockAgentPath };
}
@@ -1,11 +1,25 @@
import { describe, expect, test } from "bun:test";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ThreadId } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { putSchema } from "@ocas/core";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { createMarker, deleteMarker } from "../background/index.js";
import { cmdThreadShow, cmdThreadStart } from "../commands/thread.js";
import { appendThreadHistory, loadThreadsIndex } from "../store.js";
import {
appendThreadHistory,
createUwfStore,
loadThreadsIndex,
saveThreadsIndex,
} from "../store.js";
const OUTPUT_SCHEMA = {
type: "object" as const,
properties: {
$status: { type: "string" as const },
question: { type: "string" as const },
},
};
const TEST_WORKFLOW_YAML = `
name: test-status
@@ -36,6 +50,77 @@ graph:
location: null
`;
const SUSPEND_WORKFLOW_YAML = `
name: test-suspend-status
description: Test workflow for suspended status
roles:
worker:
description: Worker role
goal: Work
capabilities: ["coding"]
procedure: Work
output: |
$status: "needs_input"
question: "Which API?"
frontmatter:
oneOf:
- type: object
required: ["$status", "question"]
properties:
$status: { const: "needs_input" }
question: { type: string }
graph:
$START:
_:
role: worker
prompt: "Start work"
location: null
worker:
needs_input:
role: $SUSPEND
prompt: "Please clarify: {{{question}}}"
location: null
`;
async function insertStepNode(
storageRoot: string,
threadId: ThreadId,
role: string,
outputPayload: Record<string, unknown>,
): Promise<void> {
const uwf = await createUwfStore(storageRoot);
const index = await loadThreadsIndex(storageRoot);
const headEntry = index[threadId];
if (headEntry === undefined) throw new Error(`thread ${threadId} not in index`);
const head = headEntry.head;
const outputSchemaHash = await putSchema(uwf.store, OUTPUT_SCHEMA);
const outputHash = await uwf.store.put(outputSchemaHash, outputPayload);
const detailHash = await uwf.store.put(uwf.schemas.text, "detail-placeholder");
const headNode = uwf.store.get(head);
if (headNode === null) throw new Error(`head ${head} not found`);
const isStart = headNode.type === uwf.schemas.startNode;
const startHash = isStart ? head : (headNode.payload as { start: CasRef }).start;
const stepHash = (await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: isStart ? null : head,
role,
output: outputHash,
detail: detailHash,
agent: "uwf-test",
edgePrompt: "edge",
startedAtMs: Date.now(),
completedAtMs: Date.now() + 1,
cwd: "/tmp",
assembledPrompt: null,
})) as CasRef;
index[threadId] = { head: stepHash, suspendedRole: null, suspendMessage: null };
await saveThreadsIndex(storageRoot, index);
}
describe("thread show status field", () => {
let tmpDir: string;
let storageRoot: string;
@@ -119,7 +204,7 @@ describe("thread show status field", () => {
// Get the head hash before moving to history
const index = await loadThreadsIndex(storageRoot);
const head = index[threadId];
const head = index[threadId]!.head;
if (!head) throw new Error("Thread not found in index");
// Move thread to history with reason 'completed'
@@ -159,7 +244,7 @@ describe("thread show status field", () => {
// Get the head hash before moving to history
const index = await loadThreadsIndex(storageRoot);
const head = index[threadId];
const head = index[threadId]!.head;
if (!head) throw new Error("Thread not found in index");
// Move thread to history with reason 'cancelled'
@@ -199,7 +284,7 @@ describe("thread show status field", () => {
// Get the head hash before moving to history
const index = await loadThreadsIndex(storageRoot);
const head = index[threadId];
const head = index[threadId]!.head;
if (!head) throw new Error("Thread not found in index");
// Move thread to history with reason null (legacy format)
@@ -224,4 +309,42 @@ describe("thread show status field", () => {
await teardown();
});
test("active suspended thread shows status 'suspended'", async () => {
await setupTestEnv();
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const originalCasDir = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = casDir;
try {
const workflowPath = join(tmpDir, "test-suspend-status.yaml");
await writeFile(workflowPath, SUSPEND_WORKFLOW_YAML, "utf8");
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
const threadId = startResult.thread as ThreadId;
await insertStepNode(storageRoot, threadId, "worker", {
$status: "needs_input",
question: "Which API?",
});
const result = await cmdThreadShow(storageRoot, threadId);
expect(result.status).toBe("suspended");
expect(result.done).toBe(false);
expect(result.currentRole).toBe(null);
expect(result.suspendedRole).toBe("worker");
expect(result.suspendMessage).toBe("Please clarify: Which API?");
expect(result.background).toBe(null);
expect(result.thread).toBe(threadId);
} finally {
if (originalCasDir === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalCasDir;
}
await teardown();
}
});
});
@@ -1,9 +1,9 @@
import { describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasRef, StartNodePayload, ThreadId } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { cmdThreadStart } from "../commands/thread.js";
import { createUwfStore, loadThreadsIndex } from "../store.js";
@@ -75,7 +75,7 @@ graph:
async function getStartNodeCwd(threadId: string): Promise<string> {
const uwf = await createUwfStore(storageRoot);
const index = await loadThreadsIndex(storageRoot);
const headHash = index[threadId as ThreadId];
const headHash = index[threadId as ThreadId]!.head;
expect(headHash).toBeDefined();
const startNode = uwf.store.get(headHash as CasRef);
@@ -136,14 +136,14 @@ graph:
const uwfBin = join(process.cwd(), "dist", "cli.js");
// Register the workflow
execFileSync("node", [uwfBin, "workflow", "add", workflowPath], {
execFileSync("bun", [uwfBin, "workflow", "add", workflowPath], {
env: { ...process.env, UWF_STORAGE_ROOT: storageRoot, UNCAGED_CAS_DIR: casDir },
encoding: "utf8",
});
// Verify CLI accepts --cwd option (no error thrown)
const output = execFileSync(
"node",
"bun",
[uwfBin, "thread", "start", "test-cwd-cli", "-p", "test prompt", "--cwd", testCwd],
{
env: { ...process.env, UWF_STORAGE_ROOT: storageRoot, UNCAGED_CAS_DIR: casDir },
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
const CLI_PATH = join(import.meta.dirname, "..", "cli.js");
@@ -0,0 +1,179 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/workflow-protocol";
import { parse } from "yaml";
import { cmdThreadShow } from "../commands/thread.js";
import { registerUwfSchemas } from "../schemas.js";
import { saveThreadsIndex } from "../store.js";
const OUTPUT_SCHEMA = {
type: "object" as const,
properties: {
$status: { type: "string" as const },
question: { type: "string" as const },
},
required: ["$status"],
additionalProperties: false,
};
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-suspend-step-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
describe("suspend step CAS chain and threads.yaml metadata", () => {
test("thread exec records suspend step in CAS and suspend metadata in threads.yaml", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const originalCasDir = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = casDir;
try {
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const outputSchemaHash = await putSchema(store, OUTPUT_SCHEMA);
const workflowHash = await store.put(schemas.workflow, {
name: "test-suspend-step",
description: "suspend step integration test",
roles: {
worker: {
description: "Worker role",
goal: "Work",
capabilities: [],
procedure: "work",
output: "result",
frontmatter: outputSchemaHash,
},
},
graph: {
$START: { _: { role: "worker", prompt: "Start work", location: null } },
worker: {
needs_input: {
role: "$SUSPEND",
prompt: "Please clarify: {{{question}}}",
location: null,
},
},
},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test suspend task",
cwd: tmpDir,
});
const threadId = "01SUSPENDSTEPTEST0000000" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: startHash });
const outputHash = await store.put(outputSchemaHash, {
$status: "needs_input",
question: "Which API?",
});
const detailHash = await store.put(schemas.text, "mock detail");
const startedAtMs = 1716600000000;
const completedAtMs = 1716600001500;
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-mock",
edgePrompt: "Start work",
startedAtMs,
completedAtMs,
cwd: tmpDir,
assembledPrompt: null,
});
const mockAgentPath = join(tmpDir, "mock-agent.sh");
const adapterJson = JSON.stringify({
stepHash,
detailHash,
role: "worker",
frontmatter: { $status: "needs_input", question: "Which API?" },
body: "",
startedAtMs,
completedAtMs,
});
await writeFile(mockAgentPath, `#!/bin/sh\necho '${adapterJson}'\n`, { mode: 0o755 });
const configPath = join(tmpDir, "config.yaml");
await writeFile(
configPath,
`defaultAgent: uwf-hermes\ndefaultModel: test-model\nagentOverrides: null\nagents: {}\nproviders: {}\nmodels: {}\n`,
);
const cliPath = join(import.meta.dirname, "..", "cli.js");
const stdout = execFileSync(
"bun",
["run", cliPath, "thread", "exec", threadId, "--agent", mockAgentPath],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
WORKFLOW_STORAGE_ROOT: tmpDir,
UNCAGED_CAS_DIR: casDir,
},
cwd: tmpDir,
timeout: 30000,
},
);
const cliOutput = JSON.parse(stdout.trim());
expect(cliOutput.status).toBe("suspended");
expect(cliOutput.head).toBe(stepHash);
expect(cliOutput.suspendedRole).toBe("worker");
expect(cliOutput.suspendMessage).toBe("Please clarify: Which API?");
const storeAfter = createFsStore(casDir);
const stepNode = storeAfter.get(cliOutput.head as CasRef);
expect(stepNode).not.toBeNull();
const payload = stepNode!.payload as StepNodePayload;
expect(payload.role).toBe("worker");
expect(payload.output).toBe(outputHash);
const outputNode = storeAfter.get(outputHash);
expect(outputNode?.payload).toEqual({
$status: "needs_input",
question: "Which API?",
});
const threadsYaml = await readFile(join(tmpDir, "threads.yaml"), "utf8");
const threadsIndex = parse(threadsYaml) as Record<string, unknown>;
const threadEntry = threadsIndex[threadId];
expect(threadEntry).toEqual({
head: stepHash,
suspendedRole: "worker",
suspendMessage: "Please clarify: Which API?",
});
const showResult = await cmdThreadShow(tmpDir, threadId);
expect(showResult.status).toBe("suspended");
expect(showResult.suspendMessage).toBe("Please clarify: Which API?");
expect(showResult.suspendedRole).toBe("worker");
} finally {
if (originalCasDir === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalCasDir;
}
}
});
});
@@ -0,0 +1,286 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { putSchema } from "@ocas/core";
import type { ThreadId } from "@uncaged/workflow-protocol";
import { createThreadIndexEntry, markThreadSuspended } from "@uncaged/workflow-protocol";
import { cmdThreadList, cmdThreadShow } from "../commands/thread.js";
import { createUwfStore, saveThreadsIndex } from "../store.js";
const OUTPUT_SCHEMA = {
type: "object" as const,
properties: {
$status: { type: "string" as const },
question: { type: "string" as const },
},
required: ["$status"],
additionalProperties: false,
};
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-suspended-display-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
describe("suspended thread display", () => {
test("thread list shows [suspended] marker for suspended threads", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const originalCasDir = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = casDir;
try {
const uwf = await createUwfStore(tmpDir);
const outputSchemaHash = await putSchema(uwf.store, OUTPUT_SCHEMA);
// Create test workflow with suspend capability
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-suspend-display",
description: "test suspended display",
roles: {
worker: {
description: "Worker role",
goal: "Work and potentially suspend",
capabilities: [],
procedure: "work",
output: "result",
frontmatter: outputSchemaHash,
},
},
graph: {
$START: { _: { role: "worker", prompt: "Start work", location: null } },
worker: {
needs_input: {
role: "$SUSPEND",
prompt: "Please provide more details: {{{question}}}",
location: null,
},
},
},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Test task requiring input",
cwd: tmpDir,
});
// Create suspended thread
const suspendedThreadId = "01SUSPENDEDTHREAD0000000" as ThreadId;
const outputHash = await uwf.store.put(outputSchemaHash, {
$status: "needs_input",
question: "What is the target API?",
});
const detailHash = await uwf.store.put(uwf.schemas.text, "mock detail");
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-mock",
edgePrompt: "Start work",
startedAtMs: 1716600000000,
completedAtMs: 1716600001500,
cwd: tmpDir,
assembledPrompt: null,
});
// Create suspended thread entry in threads.yaml
const suspendedEntry = markThreadSuspended(
createThreadIndexEntry(stepHash),
"worker",
"Please provide more details: What is the target API?",
);
// Create normal (idle) thread
const idleThreadId = "01IDLETHREAD00000000000" as ThreadId;
const idleStartHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Normal task",
cwd: tmpDir,
});
const idleEntry = createThreadIndexEntry(idleStartHash);
await saveThreadsIndex(tmpDir, {
[suspendedThreadId]: suspendedEntry,
[idleThreadId]: idleEntry,
});
// Test thread list
const listResult = await cmdThreadList(tmpDir, null, null, null, null, null);
// Find the suspended and idle threads in results
const suspendedItem = listResult.find((item) => item.thread === suspendedThreadId);
const idleItem = listResult.find((item) => item.thread === idleThreadId);
expect(suspendedItem).toBeDefined();
expect(suspendedItem!.status).toBe("suspended");
expect(suspendedItem!.statusDisplay).toBe("suspended [suspended]");
expect(idleItem).toBeDefined();
expect(idleItem!.status).toBe("idle");
expect(idleItem!.statusDisplay).toBe("idle");
} finally {
if (originalCasDir === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalCasDir;
}
}
});
test("thread show displays suspend info and resume hint", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const originalCasDir = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = casDir;
try {
const uwf = await createUwfStore(tmpDir);
const outputSchemaHash = await putSchema(uwf.store, OUTPUT_SCHEMA);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-suspend-show",
description: "test suspended show",
roles: {
worker: {
description: "Worker role",
goal: "Work and potentially suspend",
capabilities: [],
procedure: "work",
output: "result",
frontmatter: outputSchemaHash,
},
},
graph: {
$START: { _: { role: "worker", prompt: "Start work", location: null } },
worker: {
needs_input: {
role: "$SUSPEND",
prompt: "Need clarification: {{{question}}}",
location: null,
},
},
},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Test task",
cwd: tmpDir,
});
const threadId = "01SUSPENDSHOW000000000" as ThreadId;
const outputHash = await uwf.store.put(outputSchemaHash, {
$status: "needs_input",
question: "Which database to use?",
});
const detailHash = await uwf.store.put(uwf.schemas.text, "mock detail");
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-mock",
edgePrompt: "Start work",
startedAtMs: 1716600000000,
completedAtMs: 1716600001500,
cwd: tmpDir,
assembledPrompt: null,
});
const suspendedEntry = markThreadSuspended(
createThreadIndexEntry(stepHash),
"worker",
"Need clarification: Which database to use?",
);
await saveThreadsIndex(tmpDir, { [threadId]: suspendedEntry });
// Test thread show
const showResult = await cmdThreadShow(tmpDir, threadId);
expect(showResult.status).toBe("suspended");
expect(showResult.suspendedRole).toBe("worker");
expect(showResult.suspendMessage).toBe("Need clarification: Which database to use?");
expect(showResult.hint).toBe(
`Thread is suspended. Resume with: uwf thread resume ${threadId}`,
);
} finally {
if (originalCasDir === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalCasDir;
}
}
});
test("non-suspended threads do not show suspend markers or hints", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const originalCasDir = process.env.UNCAGED_CAS_DIR;
process.env.UNCAGED_CAS_DIR = casDir;
try {
const uwf = await createUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-normal",
description: "test normal thread",
roles: {
worker: {
description: "Worker role",
goal: "Work normally",
capabilities: [],
procedure: "work",
output: "result",
},
},
graph: {
$START: { _: { role: "worker", prompt: "Start work", location: null } },
},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Normal task",
cwd: tmpDir,
});
const threadId = "01NORMALTHREAD000000000" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: createThreadIndexEntry(startHash) });
// Test thread show
const showResult = await cmdThreadShow(tmpDir, threadId);
expect(showResult.status).toBe("idle");
expect(showResult.suspendedRole).toBeNull();
expect(showResult.suspendMessage).toBeNull();
expect(showResult.hint).toBeNull();
// Test thread list
const listResult = await cmdThreadList(tmpDir, null, null, null, null, null);
const threadItem = listResult.find((item) => item.thread === threadId);
expect(threadItem).toBeDefined();
expect(threadItem!.status).toBe("idle");
expect(threadItem!.statusDisplay).toBe("idle");
} finally {
if (originalCasDir === undefined) {
delete process.env.UNCAGED_CAS_DIR;
} else {
process.env.UNCAGED_CAS_DIR = originalCasDir;
}
}
});
});
@@ -1,10 +1,10 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdStepList, cmdStepShow } from "../commands/step.js";
import {
cmdThreadRead,
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import type { WorkflowPayload } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { validateWorkflow } from "../validate-semantic.js";
/** Build a valid two-role workflow that passes all checks. */
@@ -1,9 +1,9 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createFsStore } from "@ocas/fs";
import type { CasRef, WorkflowPayload } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { stringify } from "yaml";
import { cmdThreadStart } from "../commands/thread.js";
import { registerUwfSchemas } from "../schemas.js";
+63 -25
View File
@@ -15,21 +15,24 @@ import {
} from "./commands/cas.js";
import { cmdConfigGet, cmdConfigList, cmdConfigSet } from "./commands/config.js";
import { cmdLogClean, cmdLogList, cmdLogShow } from "./commands/log.js";
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
import {
cmdSkillAdapter,
cmdSkillAuthor,
cmdSkillBootstrap,
cmdSkillDeveloper,
cmdSkillList,
cmdSkillUser,
} from "./commands/skill.js";
cmdPromptAdapter,
cmdPromptAuthor,
cmdPromptBootstrap,
cmdPromptDeveloper,
cmdPromptList,
cmdPromptSetup,
cmdPromptUsage,
cmdPromptUser,
} from "./commands/prompt.js";
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
import { cmdStepFork, cmdStepList, cmdStepRead, cmdStepShow } from "./commands/step.js";
import {
cmdThreadCancel,
cmdThreadExec,
cmdThreadList,
cmdThreadRead,
cmdThreadResume,
cmdThreadShow,
cmdThreadStart,
cmdThreadStop,
@@ -189,11 +192,11 @@ function parseStatusFilter(status: string | undefined): ThreadStatus[] | null {
if (raw === "active") return ["idle", "running"];
const parts = raw.split(",").map((s) => s.trim());
const validStatuses: ThreadStatus[] = ["idle", "running", "completed", "cancelled"];
const validStatuses: ThreadStatus[] = ["idle", "running", "suspended", "completed", "cancelled"];
for (const part of parts) {
if (!validStatuses.includes(part as ThreadStatus)) {
process.stderr.write(
`Invalid status: ${part}. Must be one of: idle, running, completed, cancelled, active\n`,
`Invalid status: ${part}. Must be one of: idle, running, suspended, completed, cancelled, active\n`,
);
process.exit(1);
}
@@ -280,6 +283,27 @@ thread
},
);
thread
.command("resume")
.description("Resume a suspended thread and re-run the suspended role")
.argument("<thread-id>", "Thread ULID")
.option("-p, --prompt <text>", "Supplementary info to append to the resume prompt")
.option("--agent <cmd>", "Override agent command")
.action((threadId: string, opts: { prompt: string | undefined; agent: string | undefined }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const supplement = opts.prompt ?? null;
const agentOverride = opts.agent ?? null;
const result = await cmdThreadResume(
storageRoot,
threadId as ThreadId,
supplement,
agentOverride,
);
writeOutput(result);
});
});
thread
.command("stop")
.description("Stop background execution of a thread (keep thread active)")
@@ -492,49 +516,63 @@ For more information, see: uwf help thread list
process.exit(1);
});
const skill = program.command("skill").description("Built-in skill references for agents");
skill.addHelpCommand(false);
const prompt = program.command("prompt").description("Built-in prompt references for agents");
prompt.addHelpCommand(false);
skill
prompt
.command("usage")
.description("Print the complete skill content (all references combined)")
.action(() => {
console.log(cmdPromptUsage());
});
prompt
.command("setup")
.description("Print setup instructions for installing the uwf skill")
.action(() => {
console.log(cmdPromptSetup());
});
prompt
.command("adapter")
.description("Print the adapter reference (building agent adapters)")
.action(() => {
console.log(cmdSkillAdapter());
console.log(cmdPromptAdapter());
});
skill
prompt
.command("author")
.description("Print the author reference (workflow YAML design guide)")
.action(() => {
console.log(cmdSkillAuthor());
console.log(cmdPromptAuthor());
});
skill
prompt
.command("developer")
.description("Print the developer reference (coding conventions + architecture)")
.action(() => {
console.log(cmdSkillDeveloper());
console.log(cmdPromptDeveloper());
});
skill
prompt
.command("user")
.description("Print the user reference (CLI guide + typical workflows)")
.action(() => {
console.log(cmdSkillUser());
console.log(cmdPromptUser());
});
skill
prompt
.command("bootstrap")
.description("Print the bootstrap skill YAML for Hermes agents")
.action(() => {
console.log(cmdSkillBootstrap());
console.log(cmdPromptBootstrap());
});
skill
prompt
.command("list")
.description("List all available skill names")
.description("List all available prompt names")
.action(() => {
console.log(cmdSkillList().join("\n"));
console.log(cmdPromptList().join("\n"));
});
program
@@ -0,0 +1,101 @@
import {
generateAdapterReference,
generateAuthorReference,
generateBootstrapReference,
generateDeveloperReference,
generateUserReference,
} from "@uncaged/workflow-util";
export {
generateAdapterReference as cmdPromptAdapter,
generateAuthorReference as cmdPromptAuthor,
generateBootstrapReference as cmdPromptBootstrap,
generateDeveloperReference as cmdPromptDeveloper,
generateUserReference as cmdPromptUser,
};
const PROMPT_ENTRIES: ReadonlyArray<{ name: string; generate: () => string }> = [
{ name: "user", generate: generateUserReference },
{ name: "author", generate: generateAuthorReference },
{ name: "developer", generate: generateDeveloperReference },
{ name: "adapter", generate: generateAdapterReference },
{ name: "bootstrap", generate: generateBootstrapReference },
];
export function cmdPromptList(): ReadonlyArray<string> {
return PROMPT_ENTRIES.map((e) => e.name);
}
export function cmdPromptUsage(): string {
return PROMPT_ENTRIES.map((e) => e.generate()).join("\n\n---\n\n");
}
export function cmdPromptSetup(): string {
return `# uwf Skill Setup
You are being asked to install or update the uwf (United Workforce) skill
so that you know how to use the \`uwf\` CLI for workflow orchestration.
## Steps
1. **Check if uwf CLI is installed:**
\`\`\`bash
uwf --help
\`\`\`
If not installed: \`bun add -g @uncaged/cli-workflow\`
2. **Clean up old versions of the skill** (if any exist):
- Look for any existing uwf/workflow skill files in your skill directories
(e.g. \`~/.hermes/skills/uwf/SKILL.md\` or similar paths).
- If a skill exists, read its \`version\` from the YAML frontmatter.
- If the version matches the current CLI version (\`uwf --version\`), you're done — no update needed.
- If the version is older, **delete the old skill file** before proceeding.
3. **Get the usage skill content:**
\`\`\`bash
uwf prompt usage
\`\`\`
4. **Create a SKILL.md file** in your skill directory (e.g. \`~/.hermes/skills/uwf/SKILL.md\`).
The output of \`uwf prompt usage\` is the skill body WITHOUT frontmatter.
You must prepend the following YAML frontmatter:
\`\`\`yaml
---
name: uwf
description: "Use when orchestrating multi-agent workflows with uwf (United Workforce). Covers CLI usage, workflow YAML authoring, moderator logic, and agent development."
version: <VERSION>
author: United Workforce
license: MIT
metadata:
hermes:
tags: [workflow, agents, orchestration, cli, yaml]
---
\`\`\`
Replace \`<VERSION>\` with the installed CLI version (from \`uwf --version\`).
5. **Verify** the skill is loadable by your agent framework.
## Individual prompts
You can also get individual reference sections:
\`\`\`bash
uwf prompt list # list available prompt names
uwf prompt user # user reference (CLI guide + typical workflows)
uwf prompt author # author reference (workflow YAML design guide)
uwf prompt developer # developer reference (coding conventions + architecture)
uwf prompt adapter # adapter reference (building agent adapters)
uwf prompt bootstrap # bootstrap skill YAML for Hermes agents
\`\`\`
## Notes
- The skill content is bundled with the CLI and versioned with it always use
\`uwf prompt usage\` to get the content matching your installed version.
- Do NOT hand-edit the skill body. If the CLI is updated, re-run \`uwf prompt setup\`
and follow the steps again.
- When upgrading, always delete the old skill first to avoid stale instructions.
`;
}
+1 -1
View File
@@ -203,7 +203,7 @@ function collectOrderedSteps(
async function resolveHeadHash(storageRoot: string, threadId: ThreadId): Promise<CasRef> {
const index = await loadThreadsIndex(storageRoot);
const activeHead = index[threadId];
const activeHead = index[threadId]?.head;
if (activeHead !== undefined) {
return activeHead;
}
@@ -1,13 +0,0 @@
export {
generateAdapterReference as cmdSkillAdapter,
generateAuthorReference as cmdSkillAuthor,
generateBootstrapReference as cmdSkillBootstrap,
generateDeveloperReference as cmdSkillDeveloper,
generateUserReference as cmdSkillUser,
} from "@uncaged/workflow-util";
const SKILL_NAMES = ["user", "author", "developer", "adapter", "bootstrap"] as const;
export function cmdSkillList(): ReadonlyArray<string> {
return [...SKILL_NAMES];
}
+1 -1
View File
@@ -113,7 +113,7 @@ export async function cmdStepFork(
const newThreadId = generateUlid(Date.now()) as ThreadId;
const index = await loadThreadsIndex(storageRoot);
index[newThreadId] = stepHash;
index[newThreadId] = { head: stepHash, suspendedRole: null, suspendMessage: null };
await saveThreadsIndex(storageRoot, index);
return {
+417 -86
View File
@@ -11,12 +11,18 @@ import type {
StepNodePayload,
StepOutput,
ThreadId,
ThreadIndexEntry,
ThreadListItem,
ThreadStatus,
ThreadsIndex,
WorkflowConfig,
WorkflowPayload,
} from "@uncaged/workflow-protocol";
import {
createThreadIndexEntry,
markThreadSuspended,
updateThreadHead,
} from "@uncaged/workflow-protocol";
import {
createProcessLogger,
extractUlidTimestamp,
@@ -29,7 +35,7 @@ import { config as loadDotenv } from "dotenv";
import { parse } from "yaml";
import { createMarker, deleteMarker, isThreadRunning } from "../background/index.js";
import { createIncludeTag } from "../include.js";
import { evaluate } from "../moderator/index.js";
import { evaluate, isSuspendResult } from "../moderator/index.js";
import {
appendThreadHistory,
createUwfStore,
@@ -58,9 +64,120 @@ const END_ROLE = "$END";
const START_ROLE = "$START";
export const THREAD_READ_DEFAULT_QUOTA = 4000;
function buildStepOutputFromEvaluation(
workflowHash: CasRef,
threadId: ThreadId,
head: CasRef,
status: ThreadStatus,
evaluation: ReturnType<typeof evaluate>,
background: boolean | null,
): StepOutput {
const done = status === "completed";
let currentRole: string | null = null;
let suspendedRole: string | null = null;
let suspendMessage: string | null = null;
if (evaluation.ok) {
if (isSuspendResult(evaluation.value)) {
suspendedRole = evaluation.value.suspendedRole;
suspendMessage = evaluation.value.prompt;
} else if (evaluation.value.role !== END_ROLE) {
currentRole = evaluation.value.role;
}
}
return {
workflow: workflowHash,
thread: threadId,
head,
status,
currentRole,
suspendedRole,
suspendMessage,
done,
background,
};
}
function resolveSuspendFieldsFromGraph(
uwf: UwfStore,
head: CasRef,
workflowRef: CasRef,
): { suspendedRole: string | null; suspendMessage: string | null } {
const chain = walkChain(uwf, head);
const { lastRole, lastOutput } = resolveEvaluateArgs(uwf, chain);
const workflow = loadWorkflowPayload(uwf, workflowRef);
const result = evaluate(workflow.graph, lastRole, lastOutput);
if (result.ok && isSuspendResult(result.value)) {
return {
suspendedRole: result.value.suspendedRole,
suspendMessage: result.value.prompt,
};
}
return { suspendedRole: null, suspendMessage: null };
}
function resolveSuspendFieldsForShow(
entry: ThreadIndexEntry,
status: ThreadStatus,
uwf: UwfStore,
head: CasRef,
workflowRef: CasRef,
): { suspendedRole: string | null; suspendMessage: string | null } {
if (status !== "suspended") {
return { suspendedRole: null, suspendMessage: null };
}
if (entry.suspendedRole !== null && entry.suspendMessage !== null) {
return { suspendedRole: entry.suspendedRole, suspendMessage: entry.suspendMessage };
}
const fromGraph = resolveSuspendFieldsFromGraph(uwf, head, workflowRef);
return {
suspendedRole: entry.suspendedRole ?? fromGraph.suspendedRole,
suspendMessage: entry.suspendMessage ?? fromGraph.suspendMessage,
};
}
async function ensureThreadSuspendMetadata(
storageRoot: string,
threadId: ThreadId,
entry: ThreadIndexEntry,
suspendedRole: string,
suspendMessage: string,
): Promise<ThreadIndexEntry> {
if (entry.suspendedRole !== null && entry.suspendMessage !== null) {
return entry;
}
const updated = markThreadSuspended(entry, suspendedRole, suspendMessage);
const index = await loadThreadsIndex(storageRoot);
index[threadId] = updated;
await saveThreadsIndex(storageRoot, index);
return updated;
}
async function resolveActiveThreadStatus(
storageRoot: string,
threadId: ThreadId,
uwf: UwfStore,
head: CasRef,
workflowRef: CasRef,
): Promise<ThreadStatus> {
const runningMarker = await isThreadRunning(storageRoot, threadId);
if (runningMarker !== null) {
return "running";
}
const chain = walkChain(uwf, head);
const { lastRole, lastOutput } = resolveEvaluateArgs(uwf, chain);
const workflow = loadWorkflowPayload(uwf, workflowRef);
const result = evaluate(workflow.graph, lastRole, lastOutput);
if (result.ok && isSuspendResult(result.value)) {
return "suspended";
}
return "idle";
}
/**
* Derive the current/next role from the workflow graph and chain state.
* Returns null when the next role is $END or evaluation fails.
* Returns null when the next role is $END, thread is suspended, or evaluation fails.
*/
function resolveCurrentRole(uwf: UwfStore, head: CasRef, workflowRef: CasRef): string | null {
const chain = walkChain(uwf, head);
@@ -70,7 +187,10 @@ function resolveCurrentRole(uwf: UwfStore, head: CasRef, workflowRef: CasRef): s
if (!result.ok) {
return null;
}
return result.value.role === END_ROLE ? null : result.value.role;
if (isSuspendResult(result.value) || result.value.role === END_ROLE) {
return null;
}
return result.value.role;
}
const PL_THREAD_START = "7HNQ4B2X";
@@ -80,6 +200,25 @@ const PL_AGENT_DONE = "C6P9E3H7";
const PL_THREAD_ARCHIVED = "F4D8Q2K5";
const PL_STEP_ERROR = "B8T5N1V6";
const PL_BACKGROUND_START = "X7Q4W9M2";
const PL_THREAD_RESUME = "K2R7M4N8";
type ResumeStepConfig = {
role: string;
prompt: string;
};
type AgentStepTarget = {
role: string;
edgePrompt: string;
effectiveCwd: string;
};
function buildResumePrompt(graphPrompt: string, supplement: string | null): string {
if (supplement === null || supplement === "") {
return graphPrompt;
}
return `${graphPrompt}\n\n${supplement}`;
}
function failStep(plog: ProcessLogger, message: string): never {
plog.log(PL_STEP_ERROR, message, null);
@@ -330,7 +469,7 @@ export async function cmdThreadStart(
}
const index = await loadThreadsIndex(storageRoot);
index[threadId] = headHash;
index[threadId] = createThreadIndexEntry(headHash);
await saveThreadsIndex(storageRoot, index);
plog.log(
@@ -342,20 +481,34 @@ export async function cmdThreadStart(
return { workflow: workflowHash, thread: threadId };
}
export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Promise<StepOutput> {
export async function cmdThreadShow(
storageRoot: string,
threadId: ThreadId,
): Promise<ThreadShowOutput> {
const index = await loadThreadsIndex(storageRoot);
const activeHead = index[threadId];
if (activeHead !== undefined) {
const entry = index[threadId];
if (entry !== undefined) {
const activeHead = entry.head;
const uwf = await createUwfStore(storageRoot);
const workflow = resolveWorkflowFromHead(uwf, activeHead);
if (workflow === null) {
fail(`failed to resolve workflow from head: ${activeHead}`);
}
// Check if thread is running
const runningMarker = await isThreadRunning(storageRoot, threadId);
const status: ThreadStatus = runningMarker !== null ? "running" : "idle";
const status = await resolveActiveThreadStatus(
storageRoot,
threadId,
uwf,
activeHead,
workflow,
);
const currentRole = resolveCurrentRole(uwf, activeHead, workflow);
const suspendFields = resolveSuspendFieldsForShow(entry, status, uwf, activeHead, workflow);
const hint =
status === "suspended"
? `Thread is suspended. Resume with: uwf thread resume ${threadId}`
: null;
return {
workflow,
@@ -363,8 +516,11 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
head: activeHead,
status,
currentRole,
suspendedRole: suspendFields.suspendedRole,
suspendMessage: suspendFields.suspendMessage,
done: false,
background: null,
hint,
};
}
@@ -378,8 +534,11 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
head: hist.head,
status,
currentRole: null,
suspendedRole: null,
suspendMessage: null,
done: true,
background: null,
hint: null,
};
}
@@ -389,6 +548,13 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
export type ThreadListItemWithStatus = ThreadListItem & {
status: ThreadStatus;
currentRole: string | null;
/** Display label with status marker for suspended threads */
statusDisplay: string;
};
export type ThreadShowOutput = StepOutput & {
/** Hint message for suspended threads */
hint: string | null;
};
async function threadListItemFromActive(
@@ -402,9 +568,8 @@ async function threadListItemFromActive(
return null;
}
// Check if thread is currently running in background
const runningMarker = await isThreadRunning(storageRoot, threadId);
const status: ThreadStatus = runningMarker !== null ? "running" : "idle";
const status = await resolveActiveThreadStatus(storageRoot, threadId, uwf, head, workflow);
const statusDisplay = status === "suspended" ? `${status} [suspended]` : status;
return {
thread: threadId,
@@ -412,6 +577,7 @@ async function threadListItemFromActive(
head,
status,
currentRole: resolveCurrentRole(uwf, head, workflow),
statusDisplay,
};
}
@@ -421,13 +587,8 @@ async function collectActiveThreads(
index: ThreadsIndex,
): Promise<ThreadListItemWithStatus[]> {
const items: ThreadListItemWithStatus[] = [];
for (const [threadId, head] of Object.entries(index)) {
const item = await threadListItemFromActive(
storageRoot,
uwf,
threadId as ThreadId,
head as CasRef,
);
for (const [threadId, entry] of Object.entries(index)) {
const item = await threadListItemFromActive(storageRoot, uwf, threadId as ThreadId, entry.head);
if (item !== null) {
items.push(item);
}
@@ -445,12 +606,14 @@ async function collectCompletedThreads(
for (const entry of history) {
if (!activeIds.has(entry.thread) && !seen.has(entry.thread)) {
seen.add(entry.thread);
const status = entry.reason === "cancelled" ? "cancelled" : "completed";
items.push({
thread: entry.thread,
workflow: entry.workflow,
head: entry.head,
status: entry.reason === "cancelled" ? "cancelled" : "completed",
status,
currentRole: null,
statusDisplay: status,
});
}
}
@@ -893,6 +1056,65 @@ async function archiveThread(
});
}
export async function cmdThreadResume(
storageRoot: string,
threadId: ThreadId,
supplement: string | null,
agentOverride: string | null,
): Promise<StepOutput> {
const runningMarker = await isThreadRunning(storageRoot, threadId);
if (runningMarker !== null) {
fail(`thread already executing in background (PID: ${runningMarker.pid})`);
}
const index = await loadThreadsIndex(storageRoot);
const entry = index[threadId];
if (entry === undefined) {
fail(`thread not active: ${threadId}`);
}
const uwf = await createUwfStore(storageRoot);
const headHash = entry.head;
const chain = walkChain(uwf, headHash);
const workflowHash = chain.start.workflow;
const status = await resolveActiveThreadStatus(
storageRoot,
threadId,
uwf,
headHash,
workflowHash,
);
if (status !== "suspended") {
fail(`thread is not suspended: ${threadId} (status: ${status})`);
}
const suspendFields = resolveSuspendFieldsForShow(entry, status, uwf, headHash, workflowHash);
if (suspendFields.suspendedRole === null) {
fail(`thread is suspended but suspendedRole is missing: ${threadId}`);
}
if (suspendFields.suspendMessage === null) {
fail(`thread is suspended but suspendMessage is missing: ${threadId}`);
}
const resumePrompt = buildResumePrompt(suspendFields.suspendMessage, supplement);
const plog = createProcessLogger({
storageRoot,
context: { thread: threadId, workflow: workflowHash },
});
plog.log(
PL_THREAD_RESUME,
`resume role=${suspendFields.suspendedRole} supplement=${supplement !== null}`,
null,
);
return cmdThreadStepOnce(storageRoot, threadId, agentOverride, plog, {
role: suspendFields.suspendedRole,
prompt: resumePrompt,
});
}
export async function cmdThreadExec(
storageRoot: string,
threadId: ThreadId,
@@ -941,7 +1163,7 @@ export async function cmdThreadExec(
for (let i = 0; i < count; i++) {
const result = await cmdThreadStepOnce(storageRoot, threadId, agentOverride, plog);
results.push(result);
if (result.done) {
if (result.done || result.status === "suspended") {
break;
}
}
@@ -959,12 +1181,12 @@ async function resolveActiveThreadWorkflowHash(
threadId: ThreadId,
): Promise<CasRef> {
const index = await loadThreadsIndex(storageRoot);
const headHash = index[threadId];
if (headHash === undefined) {
const entry = index[threadId];
if (entry === undefined) {
fail(`thread not active: ${threadId}`);
}
const uwf = await createUwfStore(storageRoot);
const chain = walkChain(uwf, headHash);
const chain = walkChain(uwf, entry.head);
return chain.start.workflow;
}
@@ -978,10 +1200,11 @@ async function cmdThreadStepBackground(
): Promise<StepOutput[]> {
// Get current head to return to caller
const index = await loadThreadsIndex(storageRoot);
const headHash = index[threadId];
if (headHash === undefined) {
const entry = index[threadId];
if (entry === undefined) {
failStep(plog, `thread not active: ${threadId}`);
}
const headHash = entry.head;
const uwf = await createUwfStore(storageRoot);
@@ -1017,30 +1240,42 @@ async function cmdThreadStepBackground(
head: headHash,
status: "running",
currentRole: resolveCurrentRole(uwf, headHash, workflowHash),
suspendedRole: null,
suspendMessage: null,
done: false,
background: true,
},
];
}
async function cmdThreadStepOnce(
function resolveResumeStepTarget(
resume: ResumeStepConfig,
chain: ChainState,
threadCwd: string,
plog: ProcessLogger,
): AgentStepTarget {
const lastStep = chain.stepsNewestFirst[0];
plog.log(PL_MODERATOR, `resume role=${resume.role} prompt=${resume.prompt}`, null);
return {
role: resume.role,
edgePrompt: resume.prompt,
effectiveCwd: lastStep !== undefined && lastStep.cwd !== "" ? lastStep.cwd : threadCwd,
};
}
async function resolveModeratorStepTarget(
storageRoot: string,
threadId: ThreadId,
agentOverride: string | null,
entry: ThreadIndexEntry,
headHash: CasRef,
workflowHash: CasRef,
workflow: WorkflowPayload,
uwf: UwfStore,
chain: ChainState,
threadCwd: string,
plog: ProcessLogger,
): Promise<StepOutput> {
const index = await loadThreadsIndex(storageRoot);
const headHash = index[threadId];
if (headHash === undefined) {
failStep(plog, `thread not active: ${threadId}`);
}
const uwf = await createUwfStore(storageRoot);
const chain = walkChain(uwf, headHash);
const workflowHash = chain.start.workflow;
const workflow = loadWorkflowPayload(uwf, workflowHash);
): Promise<StepOutput | AgentStepTarget> {
const { lastRole, lastOutput } = resolveEvaluateArgs(uwf, chain);
const nextResult = evaluate(workflow.graph, lastRole, lastOutput);
if (!nextResult.ok) {
failStep(plog, `moderator evaluate failed: ${nextResult.error.message}`);
@@ -1048,10 +1283,32 @@ async function cmdThreadStepOnce(
plog.log(
PL_MODERATOR,
`moderator role=${nextResult.value.role} prompt=${nextResult.value.prompt}`,
`moderator ${
isSuspendResult(nextResult.value)
? `action=suspend suspendedRole=${nextResult.value.suspendedRole}`
: `role=${nextResult.value.role}`
} prompt=${nextResult.value.prompt}`,
null,
);
if (isSuspendResult(nextResult.value)) {
await ensureThreadSuspendMetadata(
storageRoot,
threadId,
entry,
nextResult.value.suspendedRole,
nextResult.value.prompt,
);
return buildStepOutputFromEvaluation(
workflowHash,
threadId,
headHash,
"suspended",
nextResult,
null,
);
}
if (nextResult.value.role === END_ROLE) {
plog.log(PL_THREAD_ARCHIVED, `thread archived head=${headHash}`, null);
await archiveThread(storageRoot, threadId, workflowHash, headHash);
@@ -1061,17 +1318,124 @@ async function cmdThreadStepOnce(
head: headHash,
status: "completed",
currentRole: null,
suspendedRole: null,
suspendMessage: null,
done: true,
background: null,
};
}
const role = nextResult.value.role;
const edgePrompt = nextResult.value.prompt;
return {
role: nextResult.value.role,
edgePrompt: nextResult.value.prompt,
effectiveCwd: nextResult.value.location !== null ? nextResult.value.location : threadCwd,
};
}
// Resolve cwd: use edge location if provided, otherwise inherit thread.cwd
async function finalizeAgentStep(
storageRoot: string,
threadId: ThreadId,
workflowHash: CasRef,
workflow: WorkflowPayload,
newHead: CasRef,
uwfAfter: UwfStore,
plog: ProcessLogger,
): Promise<StepOutput> {
const freshIndex = await loadThreadsIndex(storageRoot);
const priorEntry = freshIndex[threadId] ?? createThreadIndexEntry(newHead);
freshIndex[threadId] = updateThreadHead(priorEntry, newHead);
await saveThreadsIndex(storageRoot, freshIndex);
const chainAfter = walkChain(uwfAfter, newHead);
const { lastRole: lastRoleAfter, lastOutput: lastOutputAfter } = resolveEvaluateArgs(
uwfAfter,
chainAfter,
);
const afterResult = evaluate(workflow.graph, lastRoleAfter, lastOutputAfter);
if (!afterResult.ok) {
failStep(plog, `post-step moderator evaluate failed: ${afterResult.error.message}`);
}
if (isSuspendResult(afterResult.value)) {
freshIndex[threadId] = markThreadSuspended(
freshIndex[threadId] ?? createThreadIndexEntry(newHead),
afterResult.value.suspendedRole,
afterResult.value.prompt,
);
await saveThreadsIndex(storageRoot, freshIndex);
return buildStepOutputFromEvaluation(
workflowHash,
threadId,
newHead,
"suspended",
afterResult,
null,
);
}
const done = afterResult.value.role === END_ROLE;
if (done) {
plog.log(PL_THREAD_ARCHIVED, `thread archived head=${newHead}`, null);
await archiveThread(storageRoot, threadId, workflowHash, newHead);
}
const status: ThreadStatus = done ? "completed" : "idle";
const currentRole = done ? null : afterResult.value.role;
return {
workflow: workflowHash,
thread: threadId,
head: newHead,
status,
currentRole,
suspendedRole: null,
suspendMessage: null,
done,
background: null,
};
}
async function cmdThreadStepOnce(
storageRoot: string,
threadId: ThreadId,
agentOverride: string | null,
plog: ProcessLogger,
resume: ResumeStepConfig | null = null,
): Promise<StepOutput> {
const index = await loadThreadsIndex(storageRoot);
const entry = index[threadId];
if (entry === undefined) {
failStep(plog, `thread not active: ${threadId}`);
}
const headHash = entry.head;
const uwf = await createUwfStore(storageRoot);
const chain = walkChain(uwf, headHash);
const workflowHash = chain.start.workflow;
const workflow = loadWorkflowPayload(uwf, workflowHash);
const threadCwd = chain.start.cwd;
const effectiveCwd = nextResult.value.location !== null ? nextResult.value.location : threadCwd;
const targetOrOutput =
resume !== null
? resolveResumeStepTarget(resume, chain, threadCwd, plog)
: await resolveModeratorStepTarget(
storageRoot,
threadId,
entry,
headHash,
workflowHash,
workflow,
uwf,
chain,
threadCwd,
plog,
);
if ("status" in targetOrOutput) {
return targetOrOutput;
}
const { role, edgePrompt, effectiveCwd } = targetOrOutput;
const config = await loadWorkflowConfig(storageRoot);
const agent = resolveAgentConfig(config, workflow, role, agentOverride);
@@ -1086,52 +1450,18 @@ async function cmdThreadStepOnce(
plog.log(PL_AGENT_DONE, `agent returned head=${newHead}`, null);
// Re-create store to pick up nodes written by the agent subprocess
const uwfAfter = await createUwfStore(storageRoot);
const newNode = uwfAfter.store.get(newHead);
if (newNode === null || newNode.type !== uwfAfter.schemas.stepNode) {
failStep(plog, `agent returned hash that is not a StepNode: ${newHead}`);
}
// Reload threads index to avoid overwriting changes made by the agent subprocess
const freshIndex = await loadThreadsIndex(storageRoot);
freshIndex[threadId] = newHead;
await saveThreadsIndex(storageRoot, freshIndex);
const chainAfter = walkChain(uwfAfter, newHead);
const { lastRole: lastRoleAfter, lastOutput: lastOutputAfter } = resolveEvaluateArgs(
uwfAfter,
chainAfter,
);
const afterResult = evaluate(workflow.graph, lastRoleAfter, lastOutputAfter);
if (!afterResult.ok) {
failStep(plog, `post-step moderator evaluate failed: ${afterResult.error.message}`);
}
const done = afterResult.value.role === END_ROLE;
if (done) {
plog.log(PL_THREAD_ARCHIVED, `thread archived head=${newHead}`, null);
await archiveThread(storageRoot, threadId, workflowHash, newHead);
}
// Determine status based on whether thread is done and running state
const status: ThreadStatus = done ? "completed" : "idle";
const currentRole = done ? null : afterResult.value.role;
return {
workflow: workflowHash,
thread: threadId,
head: newHead,
status,
currentRole,
done,
background: null,
};
return finalizeAgentStep(storageRoot, threadId, workflowHash, workflow, newHead, uwfAfter, plog);
}
async function resolveHeadHash(storageRoot: string, threadId: ThreadId): Promise<CasRef> {
const index = await loadThreadsIndex(storageRoot);
const activeHead = index[threadId];
const activeHead = index[threadId]?.head;
if (activeHead !== undefined) {
return activeHead;
}
@@ -1184,8 +1514,8 @@ export type CancelOutput = {
*/
export async function cmdThreadStop(storageRoot: string, threadId: ThreadId): Promise<StopOutput> {
const index = await loadThreadsIndex(storageRoot);
const head = index[threadId];
if (head === undefined) {
const entry = index[threadId];
if (entry === undefined) {
fail(`thread not active: ${threadId}`);
}
@@ -1214,10 +1544,11 @@ export async function cmdThreadCancel(
threadId: ThreadId,
): Promise<CancelOutput> {
const index = await loadThreadsIndex(storageRoot);
const head = index[threadId];
if (head === undefined) {
const entry = index[threadId];
if (entry === undefined) {
fail(`thread not active: ${threadId}`);
}
const head = entry.head;
// Check if thread is running in background and terminate it
const runningMarker = await isThreadRunning(storageRoot, threadId);
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test } from "bun:test";
import { evaluate } from "../evaluate.js";
import { isSuspendResult } from "../types.js";
describe("Edge prompt template variable resolution", () => {
test("returns error when rendered prompt is empty string", () => {
@@ -107,7 +108,7 @@ describe("Moderator location resolution", () => {
const result = evaluate(graph, "planner", { $status: "ready" });
expect(result.ok).toBe(true);
if (result.ok) {
if (result.ok && !isSuspendResult(result.value)) {
expect(result.value.location).toBe(null);
}
});
@@ -126,7 +127,7 @@ describe("Moderator location resolution", () => {
const result = evaluate(graph, "planner", { $status: "ready" });
expect(result.ok).toBe(true);
if (result.ok) {
if (result.ok && !isSuspendResult(result.value)) {
expect(result.value.location).toBe("/static/path");
}
});
@@ -148,7 +149,7 @@ describe("Moderator location resolution", () => {
});
expect(result.ok).toBe(true);
if (result.ok) {
if (result.ok && !isSuspendResult(result.value)) {
expect(result.value.location).toBe("/home/user/repo");
}
});
@@ -171,7 +172,7 @@ describe("Moderator location resolution", () => {
});
expect(result.ok).toBe(true);
if (result.ok) {
if (result.ok && !isSuspendResult(result.value)) {
expect(result.value.location).toBe("/home/user/myproject");
}
});
@@ -190,7 +191,7 @@ describe("Moderator location resolution", () => {
const result = evaluate(graph, "planner", { $status: "ready" });
expect(result.ok).toBe(true);
if (result.ok) {
if (result.ok && !isSuspendResult(result.value)) {
// Mustache renders missing variables as empty string
expect(result.value.location).toBe("");
}
@@ -7,6 +7,7 @@ import type { EvaluateResult, Result } from "./types.js";
mustache.escape = (text: string) => text;
const START_ROLE = "$START";
const SUSPEND_ROLE = "$SUSPEND";
const UNIT_STATUS = "_";
type LastOutput = Record<string, unknown>;
@@ -51,6 +52,17 @@ export function evaluate(
),
};
}
if (target.role === SUSPEND_ROLE) {
return {
ok: true,
value: {
action: "suspend",
suspendedRole: lastRole,
prompt,
},
};
}
const location = target.location !== null ? mustache.render(target.location, lastOutput) : null;
return { ok: true, value: { role: target.role, prompt, location } };
} catch (error) {
+6 -1
View File
@@ -1,2 +1,7 @@
export { evaluate } from "./evaluate.js";
export type { EvaluateResult } from "./types.js";
export type {
EvaluateResult,
EvaluateRouteResult,
EvaluateSuspendResult,
} from "./types.js";
export { isSuspendResult } from "./types.js";
+17 -2
View File
@@ -1,9 +1,24 @@
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
/** The result of moderator evaluation — which role to go to, and the edge prompt. */
export type EvaluateResult = {
/** Moderator routes the thread to a real role (or `$END`). */
export type EvaluateRouteResult = {
role: string;
prompt: string;
/** Resolved working directory from edge location field (null = inherit thread cwd). */
location: string | null;
};
/** Moderator routes the thread to `$SUSPEND` — waiting for external input. */
export type EvaluateSuspendResult = {
action: "suspend";
/** Role whose output triggered the suspend transition. */
suspendedRole: string;
prompt: string;
};
/** The result of moderator evaluation. */
export type EvaluateResult = EvaluateRouteResult | EvaluateSuspendResult;
export function isSuspendResult(result: EvaluateResult): result is EvaluateSuspendResult {
return "action" in result && result.action === "suspend";
}
+30 -13
View File
@@ -5,7 +5,18 @@ import { join } from "node:path";
import type { BootstrapCapableStore, Hash } from "@ocas/core";
import { createFsStore } from "@ocas/fs";
import type { CasRef, ThreadId, ThreadListItem, ThreadsIndex } from "@uncaged/workflow-protocol";
import type {
CasRef,
ThreadId,
ThreadIndexEntry,
ThreadListItem,
ThreadsIndex,
} from "@uncaged/workflow-protocol";
import {
createThreadIndexEntry,
parseThreadsIndex,
serializeThreadsIndex,
} from "@uncaged/workflow-protocol";
import { parse, stringify } from "yaml";
import { registerUwfSchemas, type UwfSchemaHashes } from "./schemas.js";
@@ -234,16 +245,7 @@ export async function loadThreadsIndex(storageRoot: string): Promise<ThreadsInde
try {
const text = await readFile(path, "utf8");
const raw = parse(text) as unknown;
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return {};
}
const index: ThreadsIndex = {};
for (const [threadId, head] of Object.entries(raw as Record<string, unknown>)) {
if (typeof head === "string") {
index[threadId as ThreadId] = head;
}
}
return index;
return parseThreadsIndex(raw);
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
@@ -253,10 +255,25 @@ export async function loadThreadsIndex(storageRoot: string): Promise<ThreadsInde
}
}
export async function saveThreadsIndex(storageRoot: string, index: ThreadsIndex): Promise<void> {
/** Accept legacy CasRef values for test convenience. */
export type ThreadsIndexInput = Record<ThreadId, ThreadIndexEntry | CasRef>;
function normalizeThreadsIndexInput(index: ThreadsIndexInput): ThreadsIndex {
const normalized: ThreadsIndex = {};
for (const [threadId, value] of Object.entries(index)) {
normalized[threadId as ThreadId] =
typeof value === "string" ? createThreadIndexEntry(value as CasRef) : value;
}
return normalized;
}
export async function saveThreadsIndex(
storageRoot: string,
index: ThreadsIndexInput,
): Promise<void> {
const path = getThreadsPath(storageRoot);
await mkdir(storageRoot, { recursive: true });
const text = stringify(index, { indent: 2 });
const text = stringify(serializeThreadsIndex(normalizeThreadsIndexInput(index)), { indent: 2 });
await writeFile(path, text, "utf8");
}
@@ -2,7 +2,8 @@ import type { WorkflowPayload } from "@uncaged/workflow-protocol";
type SchemaObj = Record<string, unknown>;
const RESERVED_NAMES = new Set(["$START", "$END"]);
const RESERVED_NAMES = new Set(["$START", "$END", "$SUSPEND"]);
const PSEUDO_TARGETS = new Set(["$END", "$SUSPEND"]);
/** Extract mustache variable names from a prompt string. */
function extractMustacheVars(prompt: string): string[] {
@@ -110,9 +111,13 @@ function checkGraphStructure(payload: WorkflowPayload, errors: string[]): void {
errors.push("$END must not have outgoing edges");
}
if (graphNodes.has("$SUSPEND")) {
errors.push("$SUSPEND must not have outgoing edges");
}
for (const [node, statusMap] of Object.entries(payload.graph)) {
for (const [status, target] of Object.entries(statusMap)) {
if (target.role !== "$END" && !roleNames.has(target.role)) {
if (!PSEUDO_TARGETS.has(target.role) && !roleNames.has(target.role)) {
errors.push(`edge ${node}${status}: unknown target role "${target.role}"`);
}
}
@@ -129,7 +134,7 @@ function collectReachableRoles(graph: WorkflowPayload["graph"]): Set<string> {
const queue: string[] = [];
for (const target of Object.values(startEdges)) {
if (target.role !== "$END" && !reachable.has(target.role)) {
if (!PSEUDO_TARGETS.has(target.role) && !reachable.has(target.role)) {
reachable.add(target.role);
queue.push(target.role);
}
@@ -140,7 +145,7 @@ function collectReachableRoles(graph: WorkflowPayload["graph"]): Set<string> {
const edges = graph[current];
if (!edges) continue;
for (const target of Object.values(edges)) {
if (target.role !== "$END" && !reachable.has(target.role)) {
if (!PSEUDO_TARGETS.has(target.role) && !reachable.has(target.role)) {
reachable.add(target.role);
queue.push(target.role);
}
-8
View File
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
+2 -2
View File
@@ -19,8 +19,8 @@
},
"scripts": {
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
"test": "vitest run",
"test:ci": "vitest run"
"test": "bun test __tests__/",
"test:ci": "bun test __tests__/"
},
"dependencies": {
"@ocas/core": "^0.1.1",
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
@@ -19,8 +19,8 @@
},
"scripts": {
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
"test": "vitest run",
"test:ci": "vitest run"
"test": "bun test __tests__/",
"test:ci": "bun test __tests__/"
},
"dependencies": {
"@ocas/core": "^0.1.1",
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
+2 -2
View File
@@ -19,8 +19,8 @@
},
"scripts": {
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
"test": "vitest run",
"test:ci": "vitest run"
"test": "bun test __tests__/",
"test:ci": "bun test __tests__/"
},
"dependencies": {
"@ocas/core": "^0.1.1",
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
+3 -5
View File
@@ -6,8 +6,8 @@
"scripts": {
"dev": "bun server.ts",
"build": "vite build",
"test": "vitest run",
"test:ci": "vitest run"
"test": "bun test src/",
"test:ci": "bun test src/"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
@@ -33,10 +33,8 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/ui": "^4.1.7",
"tailwindcss": "^4.2.4",
"typescript": "^5.8.3",
"vite": "^8.0.13",
"vitest": "^4.1.7"
"vite": "^8.0.13"
}
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import type { Edge, Node } from "@xyflow/react";
import { describe, expect, it } from "vitest";
import { LayoutLR } from "../index.js";
function makeNode(id: string): Node {
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it } from "bun:test";
import { transIn } from "../trans-in.js";
import type { WorkFlowStep } from "../type.js";
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it } from "bun:test";
import type { AnyWorkEdge, AnyWorkNode } from "../../type.js";
import { validate } from "../validate.js";
@@ -1,14 +0,0 @@
import path from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/__tests__/**/*.test.ts"],
},
resolve: {
alias: {
"@": path.resolve(import.meta.dirname, "./src"),
},
},
});
+2 -2
View File
@@ -16,8 +16,8 @@
},
"scripts": {
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
"test": "vitest run",
"test:ci": "vitest run"
"test": "bun test src/__tests__/",
"test:ci": "bun test src/__tests__/"
},
"dependencies": {
"@ocas/core": "^0.1.1",
@@ -0,0 +1,79 @@
import { describe, expect, test } from "bun:test";
import {
createThreadIndexEntry,
markThreadSuspended,
normalizeThreadIndexEntry,
parseThreadsIndex,
serializeThreadIndexEntry,
serializeThreadsIndex,
updateThreadHead,
} from "../thread-index.js";
describe("thread-index", () => {
test("parse legacy string head hash", () => {
const entry = normalizeThreadIndexEntry("0123456789ABC");
expect(entry).toEqual({
head: "0123456789ABC",
suspendedRole: null,
suspendMessage: null,
});
});
test("parse suspended object entry", () => {
const entry = normalizeThreadIndexEntry({
head: "0123456789ABC",
suspendedRole: "worker",
suspendMessage: "Please clarify: Which API?",
});
expect(entry).toEqual({
head: "0123456789ABC",
suspendedRole: "worker",
suspendMessage: "Please clarify: Which API?",
});
});
test("serialize non-suspended entry as compact string", () => {
const entry = createThreadIndexEntry("0123456789ABC");
expect(serializeThreadIndexEntry(entry)).toBe("0123456789ABC");
});
test("serialize suspended entry as object", () => {
const entry = markThreadSuspended(
createThreadIndexEntry("0123456789ABC"),
"worker",
"Please clarify: Which API?",
);
expect(serializeThreadIndexEntry(entry)).toEqual({
head: "0123456789ABC",
suspendedRole: "worker",
suspendMessage: "Please clarify: Which API?",
});
});
test("updateThreadHead clears suspend metadata", () => {
const suspended = markThreadSuspended(
createThreadIndexEntry("OLDHEAD0123456"),
"worker",
"Waiting",
);
const resumed = updateThreadHead(suspended, "NEWHEAD01234567");
expect(resumed).toEqual({
head: "NEWHEAD01234567",
suspendedRole: null,
suspendMessage: null,
});
});
test("parseThreadsIndex round-trip", () => {
const raw = {
"01THREAD0000000000000001": "HEAD00000000001",
"01THREAD0000000000000002": {
head: "HEAD00000000002",
suspendedRole: "reviewer",
suspendMessage: "Need input",
},
};
const parsed = parseThreadsIndex(raw);
expect(serializeThreadsIndex(parsed)).toEqual(raw);
});
});
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test } from "bun:test";
import type { StartNodePayload, StepRecord, Target } from "../types.js";
describe("Protocol types for thread/edge location", () => {
+11
View File
@@ -3,10 +3,20 @@ export {
STEP_NODE_SCHEMA,
WORKFLOW_SCHEMA,
} from "./schemas.js";
export {
createThreadIndexEntry,
markThreadSuspended,
normalizeThreadIndexEntry,
parseThreadsIndex,
serializeThreadIndexEntry,
serializeThreadsIndex,
updateThreadHead,
} from "./thread-index.js";
export type {
AgentAlias,
AgentConfig,
CasRef,
GraphPseudoRole,
ModelAlias,
ModelConfig,
ModeratorContext,
@@ -28,6 +38,7 @@ export type {
Target,
ThreadForkOutput,
ThreadId,
ThreadIndexEntry,
ThreadListItem,
ThreadStatus,
ThreadStepsOutput,
+1 -1
View File
@@ -18,7 +18,7 @@ const TARGET: JSONSchema = {
type: "object",
required: ["role", "prompt"],
properties: {
role: { type: "string" },
role: { type: "string", description: "Role name or pseudo-role ($END, $SUSPEND)" },
prompt: { type: "string" },
location: {
anyOf: [{ type: "string" }, { type: "null" }],
@@ -0,0 +1,89 @@
import type { CasRef, ThreadId, ThreadIndexEntry, ThreadsIndex } from "./types.js";
/** Normalize a legacy head hash or entry object into {@link ThreadIndexEntry}. */
export function normalizeThreadIndexEntry(raw: unknown): ThreadIndexEntry | null {
if (typeof raw === "string") {
return createThreadIndexEntry(raw as CasRef);
}
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return null;
}
const rec = raw as Record<string, unknown>;
const head = rec.head;
if (typeof head !== "string") {
return null;
}
const suspendedRole = rec.suspendedRole;
const suspendMessage = rec.suspendMessage;
return {
head: head as CasRef,
suspendedRole: typeof suspendedRole === "string" ? suspendedRole : null,
suspendMessage: typeof suspendMessage === "string" ? suspendMessage : null,
};
}
export function createThreadIndexEntry(head: CasRef): ThreadIndexEntry {
return {
head,
suspendedRole: null,
suspendMessage: null,
};
}
export function updateThreadHead(_entry: ThreadIndexEntry, head: CasRef): ThreadIndexEntry {
return {
head,
suspendedRole: null,
suspendMessage: null,
};
}
export function markThreadSuspended(
entry: ThreadIndexEntry,
suspendedRole: string,
suspendMessage: string,
): ThreadIndexEntry {
return {
head: entry.head,
suspendedRole,
suspendMessage,
};
}
/** Serialize for threads.yaml — compact string when not suspended. */
export function serializeThreadIndexEntry(
entry: ThreadIndexEntry,
): string | Record<string, string> {
if (entry.suspendedRole === null || entry.suspendMessage === null) {
return entry.head;
}
return {
head: entry.head,
suspendedRole: entry.suspendedRole,
suspendMessage: entry.suspendMessage,
};
}
export function parseThreadsIndex(raw: unknown): ThreadsIndex {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return {};
}
const index: ThreadsIndex = {};
for (const [threadId, value] of Object.entries(raw as Record<string, unknown>)) {
const entry = normalizeThreadIndexEntry(value);
if (entry !== null) {
index[threadId as ThreadId] = entry;
}
}
return index;
}
export function serializeThreadsIndex(
index: ThreadsIndex,
): Record<string, string | Record<string, string>> {
const out: Record<string, string | Record<string, string>> = {};
for (const [threadId, entry] of Object.entries(index)) {
out[threadId] = serializeThreadIndexEntry(entry);
}
return out;
}
+20 -5
View File
@@ -35,8 +35,12 @@ export type RoleDefinition = {
frontmatter: CasRef;
};
/** Pseudo-role targets in workflow graph edges (not real roles). */
export type GraphPseudoRole = "$END" | "$SUSPEND";
export type Target = {
role: string;
/** Next role name, or a graph pseudo-role such as `$END` or `$SUSPEND`. */
role: string | GraphPseudoRole;
prompt: string;
/** Optional working directory override via mustache template. */
location: string | null;
@@ -79,7 +83,7 @@ export type ModeratorContext = {
// ── 4.5 CLI 输出 ────────────────────────────────────────────────────
/** Thread status — unified status representation */
export type ThreadStatus = "idle" | "running" | "completed" | "cancelled";
export type ThreadStatus = "idle" | "running" | "suspended" | "completed" | "cancelled";
/** uwf thread start */
export type StartOutput = {
@@ -90,7 +94,7 @@ export type StartOutput = {
/**
* Output from thread show and thread exec commands.
*
* @property status - Current thread status (idle/running/completed/cancelled)
* @property status - Current thread status (idle/running/suspended/completed/cancelled)
* @property done - @deprecated Use status field instead. True if thread is completed or cancelled.
* @property background - @deprecated Use status field instead. Always null in current implementation.
*/
@@ -99,12 +103,23 @@ export type StepOutput = {
thread: ThreadId;
head: CasRef;
status: ThreadStatus;
/** The current or next role. Null when completed, cancelled, or next is $END. */
/** The current or next role. Null when completed, cancelled, suspended, or next is $END. */
currentRole: string | null;
/** Role whose output triggered suspension. Null when thread is not suspended. */
suspendedRole: string | null;
/** Rendered suspend prompt for the user. Null when thread is not suspended. */
suspendMessage: string | null;
done: boolean;
background: boolean | null;
};
/** Active thread entry in ~/.uncaged/workflow/threads.yaml */
export type ThreadIndexEntry = {
head: CasRef;
suspendedRole: string | null;
suspendMessage: string | null;
};
/** uwf thread steps — single step entry */
export type StepEntry = {
hash: CasRef;
@@ -196,4 +211,4 @@ export type WorkflowConfig = {
};
/** ~/.uncaged/workflow/threads.yaml */
export type ThreadsIndex = Record<ThreadId, CasRef>;
export type ThreadsIndex = Record<ThreadId, ThreadIndexEntry>;
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { createMemoryStore, putSchema } from "@ocas/core";
import { describe, expect, test } from "vitest";
import { tryFrontmatterFastPath } from "../src/frontmatter.js";
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { createMemoryStore, putSchema } from "@ocas/core";
import { describe, expect, test } from "vitest";
import { tryFrontmatterFastPath } from "../src/frontmatter.js";
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import type { StepContext } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { buildContinuationPrompt } from "../src/build-continuation-prompt.js";
const reviewerStep: StepContext = {
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test } from "bun:test";
import { buildOutputFormatInstruction } from "../src/build-output-format-instruction.js";
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import type { RoleDefinition } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { buildRolePrompt } from "../src/build-role-prompt.js";
describe("buildRolePrompt", () => {
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test } from "bun:test";
// We need to test buildHistory indirectly through buildContext
// since buildHistory is not exported. For now, we'll test the integration
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { createMemoryStore, putSchema } from "@ocas/core";
import { describe, expect, test } from "vitest";
import { tryFrontmatterFastPath } from "../src/frontmatter.js";
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import type { ThreadId } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { getCachedSessionId, getCachePath, setCachedSessionId } from "../src/session-cache.js";
import { resolveStorageRoot } from "../src/storage.js";
+2 -2
View File
@@ -16,8 +16,8 @@
},
"scripts": {
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
"test": "vitest run",
"test:ci": "vitest run"
"test": "bun test __tests__/ src/__tests__/",
"test:ci": "bun test __tests__/ src/__tests__/"
},
"dependencies": {
"@ocas/core": "^0.1.1",
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
describe("parseArgv empty prompt error message", () => {
let stderrOutput: string;
+2 -2
View File
@@ -163,7 +163,7 @@ export async function buildContext(
const { store, schemas } = agentStore;
const index = await loadThreadsIndex(storageRoot);
const headHash = index[threadId];
const headHash = index[threadId]?.head;
if (headHash === undefined) {
fail(`thread not found in threads.yaml: ${threadId}`);
}
@@ -212,7 +212,7 @@ export async function buildContextWithMeta(
const { store, schemas } = agentStore;
const index = await loadThreadsIndex(storageRoot);
const headHash = index[threadId];
const headHash = index[threadId]?.head;
if (headHash === undefined) {
fail(`thread not found in threads.yaml: ${threadId}`);
}
+2 -11
View File
@@ -12,11 +12,11 @@ import type {
ProviderAlias,
ProviderConfig,
Scenario,
ThreadId,
ThreadsIndex,
WorkflowConfig,
WorkflowName,
} from "@uncaged/workflow-protocol";
import { parseThreadsIndex } from "@uncaged/workflow-protocol";
import { parse } from "yaml";
import { registerAgentSchemas } from "./schemas.js";
@@ -207,16 +207,7 @@ export async function loadThreadsIndex(storageRoot: string): Promise<ThreadsInde
try {
const text = await readFile(path, "utf8");
const raw = parse(text) as unknown;
if (!isRecord(raw)) {
return {};
}
const index: ThreadsIndex = {};
for (const [threadId, head] of Object.entries(raw)) {
if (typeof head === "string") {
index[threadId as ThreadId] = head;
}
}
return index;
return parseThreadsIndex(raw);
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it } from "bun:test";
import type { AgentFrontmatter } from "../src/index.js";
import { parseFrontmatterMarkdown, validateFrontmatter } from "../src/index.js";
+2 -2
View File
@@ -16,8 +16,8 @@
},
"scripts": {
"prepublishOnly": "echo 'Use bun run release from repo root' && exit 1",
"test": "vitest run",
"test:ci": "vitest run"
"test": "bun test __tests__/ src/__tests__/",
"test:ci": "bun test __tests__/ src/__tests__/"
},
"dependencies": {},
"devDependencies": {
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it } from "bun:test";
import { extractUlidTimestamp, generateUlid } from "../ulid.js";
describe("extractUlidTimestamp", () => {
-8
View File
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
passWithNoTests: true,
},
});
+5 -2
View File
@@ -65,8 +65,11 @@ roles:
- properties:
$status:
const: insufficient_info
reason:
type: string
required:
- $status
- reason
developer:
description: TDD implementation per test spec
goal: You are a developer agent. You implement code changes following TDD — write tests first, then implementation.
@@ -285,8 +288,8 @@ graph:
prompt: Analyze the issue and produce an implementation plan.
planner:
insufficient_info:
role: $END
prompt: Insufficient information to proceed; end the workflow.
role: $SUSPEND
prompt: "信息不足,需要补充:{{{reason}}}"
ready:
role: developer
prompt: 'Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}. Repo remote: {{{repoRemote}}}.'