Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4786a247ac | |||
| 6802aecb2e | |||
| e65e2aec72 | |||
| 008701ef46 | |||
| f6298c73bf | |||
| fa188ddf21 | |||
| 61ee22f647 | |||
| dbfed616f8 | |||
| f493b251db | |||
| b699200adf | |||
| 7b13e7deb4 | |||
| b1d9eebcf7 | |||
| 6b201fd73e | |||
| f67507bb32 | |||
| 00f95547d9 | |||
| f79db334a0 | |||
| 8e7aa3362a | |||
| 10b478640d | |||
| b0ef9c55a9 | |||
| a335471cc7 |
@@ -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>`
|
||||
|
||||
@@ -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}}}." }
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 };
|
||||
+45
-20
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
`;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/__tests__/**/*.test.ts"],
|
||||
passWithNoTests: true,
|
||||
},
|
||||
});
|
||||
@@ -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}}}.'
|
||||
|
||||
Reference in New Issue
Block a user