Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3146832d1b | |||
| 64f929c10d | |||
| 1ec32ae0fd | |||
| f851a087f2 | |||
| 984e6ae56d | |||
| 92f3b36b10 | |||
| a4677f8adb | |||
| 9ab6291a41 | |||
| 50a4db72b1 | |||
| dfdf0ac073 | |||
| c2c849df7e | |||
| 39f6ae692b | |||
| eb027e70f4 | |||
| 8fbbbce07e | |||
| f115718564 | |||
| 5c0eabda8e | |||
| 669af841e1 | |||
| 650313b1c2 | |||
| c40007eeaf | |||
| 1f13b1e79c | |||
| 031c3aa632 | |||
| 7b50969307 | |||
| fc6072c28c | |||
| b0e3f4a363 | |||
| 38112053a0 | |||
| 1d174ee5c9 | |||
| 6e3b32ca34 | |||
| 932bbe5c41 | |||
| 9440b9af82 | |||
| f96d6eb7c4 | |||
| 95102941f1 | |||
| 521d908719 | |||
| 02a2c00175 | |||
| 8ca7708a12 | |||
| 0fdc0fdec3 | |||
| d6eaf3fdc7 | |||
| 5dc2352ac5 | |||
| 39e2ab7f0d | |||
| 221919448e | |||
| 68b82c9574 | |||
| 335b8a4ae6 |
+31
-12
@@ -38,19 +38,26 @@ roles:
|
|||||||
capabilities:
|
capabilities:
|
||||||
- coding
|
- coding
|
||||||
procedure: |
|
procedure: |
|
||||||
Before starting any work, ensure a clean worktree:
|
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
|
||||||
1. `git checkout main && git pull` to get the latest code
|
|
||||||
2. `git checkout -b fix/<issue-number>-<short-description>` to create a fresh branch
|
Before starting any work, set up an isolated worktree:
|
||||||
- If bounced back from reviewer or tester, reuse the existing branch and rebase onto latest main:
|
1. `cd ~/repos/workflow && git fetch origin` to get latest refs
|
||||||
`git checkout main && git pull && git checkout <branch> && git rebase main`
|
2. First time (no existing branch):
|
||||||
|
- `git worktree add ~/repos/workflow-worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
|
||||||
|
- `cd ~/repos/workflow-worktrees/fix/<issue-number>-<short-slug> && bun install`
|
||||||
|
3. If bounced back from reviewer or tester (branch already exists):
|
||||||
|
- The worktree should already exist at `~/repos/workflow-worktrees/fix/<issue-number>-<short-slug>`
|
||||||
|
- `cd ~/repos/workflow-worktrees/fix/<issue-number>-<short-slug>`
|
||||||
|
- `git fetch origin && git rebase origin/main`
|
||||||
|
4. ALL subsequent work must happen inside the worktree directory.
|
||||||
|
|
||||||
Then implement TDD:
|
Then implement TDD:
|
||||||
3. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
|
5. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
|
||||||
4. If bounced back from reviewer or tester: read the previous role's output to understand what needs fixing
|
6. If bounced back from reviewer or tester: read the previous role's output to understand what needs fixing
|
||||||
5. Write tests first based on the spec
|
7. Write tests first based on the spec
|
||||||
6. Implement the code to make tests pass
|
8. Implement the code to make tests pass
|
||||||
7. Ensure `bun run build` passes with no errors
|
9. Ensure `bun run build` passes with no errors
|
||||||
8. Run `bun test` to verify all tests pass
|
10. Run `bun test` to verify all tests pass
|
||||||
output: "List all files changed and provide a summary. Frontmatter must include: status (done or failed)."
|
output: "List all files changed and provide a summary. Frontmatter must include: status (done or failed)."
|
||||||
frontmatter:
|
frontmatter:
|
||||||
type: object
|
type: object
|
||||||
@@ -66,6 +73,8 @@ roles:
|
|||||||
- code-review
|
- code-review
|
||||||
- static-analysis
|
- static-analysis
|
||||||
procedure: |
|
procedure: |
|
||||||
|
First, cd into the worktree: `cd ~/repos/workflow-worktrees/fix/<issue-number>-*` (find the exact directory)
|
||||||
|
|
||||||
Before reviewing, verify the git branch:
|
Before reviewing, verify the git branch:
|
||||||
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
|
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
|
||||||
2. If the branch doesn't correspond to the issue, flag it in your output and reject
|
2. If the branch doesn't correspond to the issue, flag it in your output and reject
|
||||||
@@ -99,6 +108,8 @@ roles:
|
|||||||
capabilities:
|
capabilities:
|
||||||
- testing
|
- testing
|
||||||
procedure: |
|
procedure: |
|
||||||
|
First, cd into the worktree: `cd ~/repos/workflow-worktrees/fix/<issue-number>-*` (find the exact directory)
|
||||||
|
|
||||||
1. Run `bun test` for automated test verification
|
1. Run `bun test` for automated test verification
|
||||||
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
|
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
|
||||||
3. Verify each scenario in the spec is covered and passing
|
3. Verify each scenario in the spec is covered and passing
|
||||||
@@ -119,13 +130,21 @@ roles:
|
|||||||
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
|
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
|
||||||
capabilities: []
|
capabilities: []
|
||||||
procedure: |
|
procedure: |
|
||||||
|
First, cd into the worktree: `cd ~/repos/workflow-worktrees/fix/<issue-number>-*` (find the exact directory)
|
||||||
|
|
||||||
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
|
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
|
||||||
1. Stage all changes: `git add -A`
|
1. Stage all changes: `git add -A`
|
||||||
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
|
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
|
||||||
3. Push the branch: `git push -u origin <branch-name>`
|
3. Push the branch: `git push -u origin <branch-name>`
|
||||||
- If push hook fails: capture the error log in your output, mark hook_failed
|
- If push hook fails: capture the error log in your output, mark hook_failed
|
||||||
4. On push success: create a PR via `tea pr create --title "..." --description "..."`
|
4. On push success: create a PR via `tea pr create --repo uncaged/workflow --title "..." --description "..."`
|
||||||
|
- The `--repo` flag is required to work in worktree directories (fixes #474 "path segment [0] is empty" error)
|
||||||
|
- If working on a different repo, extract owner/repo from: `git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/'`
|
||||||
- PR description must follow the project template: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
|
- PR description must follow the project template: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
|
||||||
|
- On tea failure: capture stderr/stdout, log the error clearly, include PR details (title, description, branch) for manual creation, and mark success=false
|
||||||
|
5. After PR creation, clean up the worktree:
|
||||||
|
- `cd ~/repos/workflow`
|
||||||
|
- `git worktree remove ~/repos/workflow-worktrees/fix/<issue-number>-<slug>`
|
||||||
output: "Include PR URL on success or error log on failure. Frontmatter must include: success (true or false)."
|
output: "Include PR URL on success or error log on failure. Frontmatter must include: success (true or false)."
|
||||||
frontmatter:
|
frontmatter:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -62,16 +62,16 @@ See [docs/architecture.md](docs/architecture.md) for the full design — three-p
|
|||||||
uwf setup
|
uwf setup
|
||||||
|
|
||||||
# 2. Register a workflow from YAML
|
# 2. Register a workflow from YAML
|
||||||
uwf workflow put examples/solve-issue.yaml
|
uwf workflow add examples/solve-issue.yaml
|
||||||
|
|
||||||
# 3. Start a thread (creates head pointer; does not execute)
|
# 3. Start a thread (creates head pointer; does not execute)
|
||||||
uwf thread start solve-issue -p "Fix the login redirect bug"
|
uwf thread start solve-issue -p "Fix the login redirect bug"
|
||||||
|
|
||||||
# 4. Execute steps (one at a time, until done)
|
# 4. Execute steps (one at a time, until done)
|
||||||
uwf thread step <thread-id>
|
uwf thread exec <thread-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `-c, --count <number>` on `thread step` to run multiple steps in one invocation. Override the agent with `--agent <cmd>`.
|
Use `-c, --count <number>` on `thread exec` to run multiple steps in one invocation. Override the agent with `--agent <cmd>`.
|
||||||
|
|
||||||
## CLI Reference
|
## CLI Reference
|
||||||
|
|
||||||
@@ -79,8 +79,9 @@ Global options: `-V, --version`, `--format <json|yaml>`, `-h, --help`.
|
|||||||
|
|
||||||
| Group | Commands |
|
| Group | Commands |
|
||||||
|-------|----------|
|
|-------|----------|
|
||||||
| **thread** | `start`, `step`, `show`, `list`, `kill`, `steps`, `read`, `fork`, `step-details` |
|
| **thread** | `start`, `exec`, `show`, `list`, `stop`, `cancel`, `read` |
|
||||||
| **workflow** | `put`, `show`, `list` |
|
| **step** | `list`, `show`, `read`, `fork` |
|
||||||
|
| **workflow** | `add`, `show`, `list` |
|
||||||
| **cas** | `get`, `put`, `put-text`, `has`, `refs`, `walk`, `reindex`, `schema list`, `schema get` |
|
| **cas** | `get`, `put`, `put-text`, `has`, `refs`, `walk`, `reindex`, `schema list`, `schema get` |
|
||||||
| **setup** | Interactive or `--provider`, `--base-url`, `--api-key`, `--model`, `--agent` |
|
| **setup** | Interactive or `--provider`, `--base-url`, `--api-key`, `--model`, `--agent` |
|
||||||
| **skill** | `cli` — print markdown reference of all uwf commands |
|
| **skill** | `cli` — print markdown reference of all uwf commands |
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ roles:
|
|||||||
2. cd to the repoPath before making any changes.
|
2. cd to the repoPath before making any changes.
|
||||||
3. Create a feature branch from the default branch.
|
3. Create a feature branch from the default branch.
|
||||||
4. Implement the plan — write code, tests, and ensure existing tests pass.
|
4. Implement the plan — write code, tests, and ensure existing tests pass.
|
||||||
5. Commit your changes with a descriptive message referencing the issue.
|
5. Run the project's lint/check command (e.g. `bun run check`, `npm run lint`) and fix ALL errors before proceeding. Build and lint must pass cleanly.
|
||||||
|
6. Commit your changes with a descriptive message referencing the issue.
|
||||||
output: "List all files changed and provide a summary of the implementation."
|
output: "List all files changed and provide a summary of the implementation."
|
||||||
frontmatter:
|
frontmatter:
|
||||||
type: object
|
type: object
|
||||||
@@ -62,7 +63,10 @@ roles:
|
|||||||
capabilities:
|
capabilities:
|
||||||
- code-review
|
- code-review
|
||||||
- static-analysis
|
- static-analysis
|
||||||
procedure: "Review the implementation against the plan. Check for bugs, edge cases, and style."
|
procedure: |
|
||||||
|
1. Run hard checks first — build (`bun run build` or equivalent) and lint (`bunx biome check .` or equivalent) MUST pass with zero errors. If they fail, reject immediately.
|
||||||
|
2. Then review code quality: correctness, edge cases, naming, project conventions (CLAUDE.md), and test coverage.
|
||||||
|
3. Only reject for hard check failures or genuine correctness/security issues. Style suggestions alone should not block approval.
|
||||||
output: "Approve or reject with detailed comments explaining your decision."
|
output: "Approve or reject with detailed comments explaining your decision."
|
||||||
frontmatter:
|
frontmatter:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -531,13 +531,25 @@ export async function executeThread(
|
|||||||
timestamp: nowMs,
|
timestamp: nowMs,
|
||||||
parentState: options.parentStateHash,
|
parentState: options.parentStateHash,
|
||||||
},
|
},
|
||||||
steps: input.steps.map((out, i) => ({
|
steps: await Promise.all(
|
||||||
role: out.role,
|
input.steps.map(async (out, i) => {
|
||||||
contentHash: out.contentHash,
|
// Resolve content for the last step (most relevant for the next agent).
|
||||||
meta: out.meta,
|
// Earlier steps only carry meta summaries to avoid bloating the prompt.
|
||||||
refs: out.refs,
|
const isLast = i === input.steps.length - 1;
|
||||||
timestamp: replayTs?.[i] ?? prefilled?.[i]?.timestamp ?? nowMs + i,
|
let content: string | null = null;
|
||||||
})),
|
if (isLast) {
|
||||||
|
content = await getContentMerklePayload(io.cas, out.contentHash);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
role: out.role,
|
||||||
|
contentHash: out.contentHash,
|
||||||
|
content,
|
||||||
|
meta: out.meta,
|
||||||
|
refs: out.refs,
|
||||||
|
timestamp: replayTs?.[i] ?? prefilled?.[i]?.timestamp ?? nowMs + i,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const runtime: WorkflowRuntime = {
|
const runtime: WorkflowRuntime = {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export type RoleStep<M extends RoleMeta> = {
|
|||||||
role: K;
|
role: K;
|
||||||
meta: M[K];
|
meta: M[K];
|
||||||
contentHash: string;
|
contentHash: string;
|
||||||
|
content: string | null;
|
||||||
refs: string[];
|
refs: string[];
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ async function buildRoleStepsFromStates<M extends RoleMeta>(
|
|||||||
cas: CasStore,
|
cas: CasStore,
|
||||||
): Promise<RoleStep<M>[]> {
|
): Promise<RoleStep<M>[]> {
|
||||||
const steps: RoleStep<M>[] = [];
|
const steps: RoleStep<M>[] = [];
|
||||||
for (const st of chronologicalStates) {
|
for (let idx = 0; idx < chronologicalStates.length; idx++) {
|
||||||
|
const st = chronologicalStates[idx];
|
||||||
if (st.payload.role === END) {
|
if (st.payload.role === END) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -79,10 +80,13 @@ async function buildRoleStepsFromStates<M extends RoleMeta>(
|
|||||||
if (contentParsed === null || contentParsed.kind !== "content") {
|
if (contentParsed === null || contentParsed.kind !== "content") {
|
||||||
throw new Error(`buildThreadContext: expected content node at ${st.payload.content}`);
|
throw new Error(`buildThreadContext: expected content node at ${st.payload.content}`);
|
||||||
}
|
}
|
||||||
|
// Resolve full text content for the last step only
|
||||||
|
const isLast = idx === chronologicalStates.length - 1;
|
||||||
steps.push({
|
steps.push({
|
||||||
role: st.payload.role,
|
role: st.payload.role,
|
||||||
meta: st.payload.meta,
|
meta: st.payload.meta,
|
||||||
contentHash: st.payload.content,
|
contentHash: st.payload.content,
|
||||||
|
content: isLast ? contentParsed.node.payload : null,
|
||||||
refs: [...contentParsed.node.refs],
|
refs: [...contentParsed.node.refs],
|
||||||
timestamp: st.payload.timestamp,
|
timestamp: st.payload.timestamp,
|
||||||
} as RoleStep<M>);
|
} as RoleStep<M>);
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ async function advanceOneRound<M extends RoleMeta>(
|
|||||||
const step = {
|
const step = {
|
||||||
role: next,
|
role: next,
|
||||||
contentHash,
|
contentHash,
|
||||||
|
content: contentPayload,
|
||||||
meta,
|
meta,
|
||||||
refs,
|
refs,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
expect(text).not.toContain("## Tools");
|
expect(text).not.toContain("## Tools");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("single step shows hash and meta, and includes tools", async () => {
|
test("single step shows meta and content, and includes tools", async () => {
|
||||||
const onlyHash = "01HASHSINGLESTEP0000000001";
|
const onlyHash = "01HASHSINGLESTEP0000000001";
|
||||||
const ctx: AgentContext = {
|
const ctx: AgentContext = {
|
||||||
start: startTask("user task"),
|
start: startTask("user task"),
|
||||||
@@ -42,6 +42,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
{
|
{
|
||||||
role: "coder",
|
role: "coder",
|
||||||
contentHash: onlyHash,
|
contentHash: onlyHash,
|
||||||
|
content: "Here is my implementation of the feature.",
|
||||||
meta: { files: ["a.ts"] },
|
meta: { files: ["a.ts"] },
|
||||||
refs: [onlyHash],
|
refs: [onlyHash],
|
||||||
timestamp: 2,
|
timestamp: 2,
|
||||||
@@ -52,13 +53,39 @@ describe("buildAgentPrompt", () => {
|
|||||||
expect(text).toContain("## Task");
|
expect(text).toContain("## Task");
|
||||||
expect(text).toContain("user task");
|
expect(text).toContain("user task");
|
||||||
expect(text).toContain("## Step: coder");
|
expect(text).toContain("## Step: coder");
|
||||||
expect(text).toContain(`ContentHash: ${onlyHash}`);
|
|
||||||
expect(text).toContain('Meta: {"files":["a.ts"]}');
|
expect(text).toContain('Meta: {"files":["a.ts"]}');
|
||||||
|
expect(text).toContain("<output>");
|
||||||
|
expect(text).toContain("Here is my implementation of the feature.");
|
||||||
|
expect(text).toContain("</output>");
|
||||||
expect(text).toContain("## Tools");
|
expect(text).toContain("## Tools");
|
||||||
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
|
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("two or more steps: previous steps are meta-only; latest step includes hash", async () => {
|
test("single step with null content omits output tag", async () => {
|
||||||
|
const onlyHash = "01HASHSINGLESTEP0000000001";
|
||||||
|
const ctx: AgentContext = {
|
||||||
|
start: startTask("user task"),
|
||||||
|
depth: 0,
|
||||||
|
bundleHash: "TESTHASH00001",
|
||||||
|
threadId: "01TEST000000000000000000TR",
|
||||||
|
currentRole: { name: "coder", systemPrompt: "Be helpful." },
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
role: "coder",
|
||||||
|
contentHash: onlyHash,
|
||||||
|
content: null,
|
||||||
|
meta: { files: ["a.ts"] },
|
||||||
|
refs: [onlyHash],
|
||||||
|
timestamp: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const text = await buildAgentPrompt(ctx);
|
||||||
|
expect(text).not.toContain("<output>");
|
||||||
|
expect(text).toContain('Meta: {"files":["a.ts"]}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two or more steps: previous steps are meta-only; latest step includes content", async () => {
|
||||||
const plannerHash = "01HASHPLANNER0000000000001";
|
const plannerHash = "01HASHPLANNER0000000000001";
|
||||||
const coderHash = "01HASHCODER0000000000000001";
|
const coderHash = "01HASHCODER0000000000000001";
|
||||||
const ctx: AgentContext = {
|
const ctx: AgentContext = {
|
||||||
@@ -71,6 +98,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
{
|
{
|
||||||
role: "planner",
|
role: "planner",
|
||||||
contentHash: plannerHash,
|
contentHash: plannerHash,
|
||||||
|
content: null,
|
||||||
meta: { plan: "short" },
|
meta: { plan: "short" },
|
||||||
refs: [plannerHash],
|
refs: [plannerHash],
|
||||||
timestamp: 2,
|
timestamp: 2,
|
||||||
@@ -78,6 +106,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
{
|
{
|
||||||
role: "coder",
|
role: "coder",
|
||||||
contentHash: coderHash,
|
contentHash: coderHash,
|
||||||
|
content: "I reviewed the code and found 4 lint issues:\n1. Missing semicolon on line 42\n2. Unused import on line 3",
|
||||||
meta: { done: true },
|
meta: { done: true },
|
||||||
refs: [coderHash],
|
refs: [coderHash],
|
||||||
timestamp: 3,
|
timestamp: 3,
|
||||||
@@ -90,10 +119,11 @@ describe("buildAgentPrompt", () => {
|
|||||||
expect(text).toContain("### Step 1: planner");
|
expect(text).toContain("### Step 1: planner");
|
||||||
expect(text).toContain('Summary: {"plan":"short"}');
|
expect(text).toContain('Summary: {"plan":"short"}');
|
||||||
expect(text).toContain("## Latest Step: coder");
|
expect(text).toContain("## Latest Step: coder");
|
||||||
expect(text).toContain(`ContentHash: ${coderHash}`);
|
|
||||||
expect(text).toContain('Meta: {"done":true}');
|
expect(text).toContain('Meta: {"done":true}');
|
||||||
|
expect(text).toContain("<output>");
|
||||||
|
expect(text).toContain("I reviewed the code and found 4 lint issues:");
|
||||||
|
expect(text).toContain("</output>");
|
||||||
expect(text).toContain("## Tools");
|
expect(text).toContain("## Tools");
|
||||||
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parentState null omits Parent Context section", async () => {
|
test("parentState null omits Parent Context section", async () => {
|
||||||
@@ -125,7 +155,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
expect(text).toContain(`uncaged-workflow cas get ${parentHash}`);
|
expect(text).toContain(`uncaged-workflow cas get ${parentHash}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("middle steps show meta summary only and latest shows hash", async () => {
|
test("middle steps show meta summary only and latest shows content", async () => {
|
||||||
const ha = "01HASHA00000000000000000001";
|
const ha = "01HASHA00000000000000000001";
|
||||||
const hb = "01HASHB00000000000000000001";
|
const hb = "01HASHB00000000000000000001";
|
||||||
const hc = "01HASHC00000000000000000001";
|
const hc = "01HASHC00000000000000000001";
|
||||||
@@ -139,6 +169,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
{
|
{
|
||||||
role: "a",
|
role: "a",
|
||||||
contentHash: ha,
|
contentHash: ha,
|
||||||
|
content: null,
|
||||||
meta: { n: 1 },
|
meta: { n: 1 },
|
||||||
refs: [ha],
|
refs: [ha],
|
||||||
timestamp: 2,
|
timestamp: 2,
|
||||||
@@ -146,6 +177,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
{
|
{
|
||||||
role: "b",
|
role: "b",
|
||||||
contentHash: hb,
|
contentHash: hb,
|
||||||
|
content: null,
|
||||||
meta: { n: 2 },
|
meta: { n: 2 },
|
||||||
refs: [hb],
|
refs: [hb],
|
||||||
timestamp: 3,
|
timestamp: 3,
|
||||||
@@ -153,6 +185,7 @@ describe("buildAgentPrompt", () => {
|
|||||||
{
|
{
|
||||||
role: "c",
|
role: "c",
|
||||||
contentHash: hc,
|
contentHash: hc,
|
||||||
|
content: "Final output from role c",
|
||||||
meta: { n: 3 },
|
meta: { n: 3 },
|
||||||
refs: [hc],
|
refs: [hc],
|
||||||
timestamp: 4,
|
timestamp: 4,
|
||||||
@@ -162,7 +195,35 @@ describe("buildAgentPrompt", () => {
|
|||||||
const text = await buildAgentPrompt(ctx);
|
const text = await buildAgentPrompt(ctx);
|
||||||
expect(text).toContain('Summary: {"n":1}');
|
expect(text).toContain('Summary: {"n":1}');
|
||||||
expect(text).toContain('Summary: {"n":2}');
|
expect(text).toContain('Summary: {"n":2}');
|
||||||
expect(text).toContain(`ContentHash: ${hc}`);
|
|
||||||
expect(text).toContain("## Latest Step: c");
|
expect(text).toContain("## Latest Step: c");
|
||||||
|
expect(text).toContain("<output>");
|
||||||
|
expect(text).toContain("Final output from role c");
|
||||||
|
expect(text).toContain("</output>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("content is truncated when exceeding quota", async () => {
|
||||||
|
const longContent = "x".repeat(20_000);
|
||||||
|
const hash = "01HASHLONG000000000000000001";
|
||||||
|
const ctx: AgentContext = {
|
||||||
|
start: startTask("task"),
|
||||||
|
depth: 0,
|
||||||
|
bundleHash: "TESTHASH00001",
|
||||||
|
threadId: "01TEST000000000000000000TR",
|
||||||
|
currentRole: { name: "r", systemPrompt: "S" },
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
role: "r",
|
||||||
|
contentHash: hash,
|
||||||
|
content: longContent,
|
||||||
|
meta: {},
|
||||||
|
refs: [],
|
||||||
|
timestamp: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const text = await buildAgentPrompt(ctx);
|
||||||
|
expect(text).toContain("<output>");
|
||||||
|
expect(text).toContain("... (truncated)");
|
||||||
|
expect(text.length).toBeLessThan(20_000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"uwf": "bun packages/cli-workflow/src/cli.ts",
|
||||||
"build": "bunx tsc --build",
|
"build": "bunx tsc --build",
|
||||||
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
|
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
|
||||||
"typecheck": "bunx tsc --build",
|
"typecheck": "bunx tsc --build",
|
||||||
|
|||||||
@@ -6,6 +6,18 @@
|
|||||||
|
|
||||||
Layer 4 entry point for the workflow engine. The `uwf` binary orchestrates one step per invocation: load thread head from `threads.yaml`, run the moderator, spawn the configured agent CLI, run extract, append a CAS step node, and update the head pointer (or archive when `$END`).
|
Layer 4 entry point for the workflow engine. The `uwf` binary orchestrates one step per invocation: load thread head from `threads.yaml`, run the moderator, spawn the configured agent CLI, run extract, append a CAS step node, and update the head pointer (or archive when `$END`).
|
||||||
|
|
||||||
|
### Four-Layer Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
workflow → thread → step → turn
|
||||||
|
模板定义 执行实例 单步结果 agent内部交互
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Workflow** (layer 1): YAML template with roles and routing graph
|
||||||
|
- **Thread** (layer 2): Single workflow execution instance
|
||||||
|
- **Step** (layer 3): One moderator→agent→extract cycle
|
||||||
|
- **Turn** (layer 4): Agent-internal interactions (use `step show` or CAS to inspect)
|
||||||
|
|
||||||
This package has no library `src/index.ts` — it is consumed as a CLI binary only.
|
This package has no library `src/index.ts` — it is consumed as a CLI binary only.
|
||||||
|
|
||||||
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`, `@uncaged/workflow-agent-kit`, `@uncaged/workflow-moderator`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`, `commander`, `dotenv`, `yaml`
|
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`, `@uncaged/workflow-agent-kit`, `@uncaged/workflow-moderator`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`, `commander`, `dotenv`, `yaml`
|
||||||
@@ -30,34 +42,58 @@ bun link packages/cli-workflow
|
|||||||
-h, --help Show help
|
-h, --help Show help
|
||||||
```
|
```
|
||||||
|
|
||||||
### Thread
|
### Thread (Layer 2: Execution Instances)
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `uwf thread start <workflow> -p <prompt>` | Create a thread without executing |
|
| `uwf thread start <workflow> -p <prompt>` | Create a thread without executing |
|
||||||
| `uwf thread step <thread-id> [--agent <cmd>] [-c <count>]` | Execute one or more moderator→agent→extract cycles |
|
| `uwf thread exec <thread-id> [--agent <cmd>] [-c <count>] [--background]` | Execute one or more moderator→agent→extract cycles |
|
||||||
| `uwf thread show <thread-id>` | Show thread head pointer |
|
| `uwf thread show <thread-id>` | Show thread head pointer |
|
||||||
| `uwf thread list [--all]` | List active threads (`--all` includes archived) |
|
| `uwf thread list [--status <status>] [--after <date>] [--before <date>] [--skip <n>] [--take <n>]` | List threads filtered by status (idle, running, completed, active, or comma-separated), time range (ISO or relative like '7d'), with pagination |
|
||||||
| `uwf thread steps <thread-id>` | List all steps chronologically |
|
|
||||||
| `uwf thread read <thread-id> [--quota N] [--before <hash>] [--start]` | Render thread as readable markdown |
|
| `uwf thread read <thread-id> [--quota N] [--before <hash>] [--start]` | Render thread as readable markdown |
|
||||||
| `uwf thread fork <step-hash>` | Fork from a specific step |
|
|
||||||
| `uwf thread step-details <step-hash>` | Dump full detail node as YAML |
|
`thread read`, `step list`, and `step show` work on both active and completed threads.
|
||||||
| `uwf thread kill <thread-id>` | Terminate and archive |
|
| `uwf thread stop <thread-id>` | Stop background execution (keep thread active) |
|
||||||
|
| `uwf thread cancel <thread-id>` | Cancel thread (stop + archive to history) |
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uwf thread start solve-issue -p "Fix the login redirect bug"
|
uwf thread start solve-issue -p "Fix the login redirect bug"
|
||||||
uwf thread step 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||||
uwf thread step 01ARZ3NDEKTSV4RRFFQ69G5FAV -c 3 --agent uwf-builtin
|
uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV -c 3 --agent uwf-builtin
|
||||||
|
uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV --background
|
||||||
|
uwf thread list --status running
|
||||||
|
uwf thread list --status active
|
||||||
|
uwf thread list --status idle,completed
|
||||||
|
uwf thread list --after 7d --take 10
|
||||||
uwf thread read 01ARZ3NDEKTSV4RRFFQ69G5FAV --quota 8000
|
uwf thread read 01ARZ3NDEKTSV4RRFFQ69G5FAV --quota 8000
|
||||||
|
uwf thread stop 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||||
```
|
```
|
||||||
|
|
||||||
### Workflow
|
### Step (Layer 3: Single Cycle Results)
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `uwf workflow put <file.yaml>` | Register a workflow from YAML |
|
| `uwf step list <thread-id>` | List all steps in a thread chronologically |
|
||||||
|
| `uwf step show <step-hash>` | Show step metadata and frontmatter |
|
||||||
|
| `uwf step read <step-hash> [--quota <chars>]` | Read a step's turns as human-readable markdown |
|
||||||
|
| `uwf step fork <step-hash>` | Fork a thread from a specific step |
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uwf step list 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||||
|
uwf step show 32GCDE899RRQ3
|
||||||
|
uwf step read 32GCDE899RRQ3 --quota 2000
|
||||||
|
uwf step fork 32GCDE899RRQ3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workflow (Layer 1: Templates)
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `uwf workflow add <file.yaml>` | Register a workflow from YAML |
|
||||||
| `uwf workflow show <name-or-hash>` | Show workflow definition |
|
| `uwf workflow show <name-or-hash>` | Show workflow definition |
|
||||||
| `uwf workflow list` | List registered workflows |
|
| `uwf workflow list` | List registered workflows |
|
||||||
|
|
||||||
@@ -99,6 +135,52 @@ Config: `~/.uncaged/workflow/config.yaml`. API keys: `~/.uncaged/workflow/.env`.
|
|||||||
| `uwf log show [--thread <id>] [--process <pid>] [--date YYYY-MM-DD]` | Show filtered log entries |
|
| `uwf log show [--thread <id>] [--process <pid>] [--date YYYY-MM-DD]` | Show filtered log entries |
|
||||||
| `uwf log clean [--before YYYY-MM-DD]` | Delete old log files |
|
| `uwf log clean [--before YYYY-MM-DD]` | Delete old log files |
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
### Breaking Changes (v0.x → v1.x)
|
||||||
|
|
||||||
|
The CLI was reorganized to clarify the four-layer architecture. **No backward compatibility** — old commands have been removed.
|
||||||
|
|
||||||
|
#### Renamed Commands
|
||||||
|
|
||||||
|
| Old Command | New Command | Notes |
|
||||||
|
|------------|-------------|-------|
|
||||||
|
| `workflow put` | `workflow add` | More intuitive verb |
|
||||||
|
| `thread step` | `thread exec` | Eliminates ambiguity with "step" noun |
|
||||||
|
| `thread list --all` | `thread list --status completed` | Unified status filtering |
|
||||||
|
|
||||||
|
#### Removed Commands (Merged)
|
||||||
|
|
||||||
|
| Old Command | New Command | Notes |
|
||||||
|
|------------|-------------|-------|
|
||||||
|
| `thread running` | `thread list --status running` | Merged into unified list |
|
||||||
|
|
||||||
|
#### Removed Commands (Split)
|
||||||
|
|
||||||
|
| Old Command | New Commands | Notes |
|
||||||
|
|------------|-------------|-------|
|
||||||
|
| `thread kill` | `thread stop` or `thread cancel` | `stop` keeps thread active, `cancel` archives it |
|
||||||
|
|
||||||
|
#### Moved Commands
|
||||||
|
|
||||||
|
| Old Command | New Command | Notes |
|
||||||
|
|------------|-------------|-------|
|
||||||
|
| `thread steps` | `step list` | Moved to step layer |
|
||||||
|
| `thread step-details` | `step show` | Moved to step layer |
|
||||||
|
| `thread fork` | `step fork` | Moved to step layer (forks are step-based) |
|
||||||
|
|
||||||
|
#### Deprecation Errors
|
||||||
|
|
||||||
|
Old commands now show helpful error messages:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ uwf thread step 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||||
|
Error: Command 'thread step' has been removed.
|
||||||
|
Use 'thread exec' instead.
|
||||||
|
|
||||||
|
For more information, see: uwf help thread exec
|
||||||
|
```
|
||||||
|
|
||||||
## Internal Structure
|
## Internal Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -109,8 +191,9 @@ src/
|
|||||||
├── validate.ts Workflow YAML validation
|
├── validate.ts Workflow YAML validation
|
||||||
├── schemas.ts CLI-local schema registration
|
├── schemas.ts CLI-local schema registration
|
||||||
└── commands/
|
└── commands/
|
||||||
├── thread.ts Thread lifecycle and step execution
|
├── thread.ts Thread lifecycle and exec
|
||||||
├── workflow.ts Workflow registry (put/show/list)
|
├── step.ts Step operations (list/show/read/fork)
|
||||||
|
├── workflow.ts Workflow registry (add/show/list)
|
||||||
├── cas.ts CAS inspection and schema ops
|
├── cas.ts CAS inspection and schema ops
|
||||||
├── setup.ts Interactive/non-interactive setup
|
├── setup.ts Interactive/non-interactive setup
|
||||||
├── skill.ts Built-in skill references
|
├── skill.ts Built-in skill references
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { mkdir, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import { cmdCasPutText } from "../commands/cas.js";
|
||||||
|
|
||||||
|
let storageRoot: string;
|
||||||
|
let uwfPath: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
storageRoot = join(
|
||||||
|
tmpdir(),
|
||||||
|
`uwf-cas-exit-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
);
|
||||||
|
await mkdir(storageRoot, { recursive: true });
|
||||||
|
|
||||||
|
// Find the uwf CLI path
|
||||||
|
uwfPath = join(__dirname, "../../src/cli.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(storageRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
type ExecResult = {
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
exitCode: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function execUwf(args: string[]): ExecResult {
|
||||||
|
try {
|
||||||
|
const stdout = execSync(`bun ${uwfPath} ${args.join(" ")}`, {
|
||||||
|
env: { ...process.env, WORKFLOW_STORAGE_ROOT: storageRoot },
|
||||||
|
encoding: "utf-8",
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
return { stdout, stderr: "", exitCode: 0 };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (
|
||||||
|
error &&
|
||||||
|
typeof error === "object" &&
|
||||||
|
"stdout" in error &&
|
||||||
|
"stderr" in error &&
|
||||||
|
"status" in error
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
stdout: (error.stdout as Buffer | string).toString(),
|
||||||
|
stderr: (error.stderr as Buffer | string).toString(),
|
||||||
|
exitCode: error.status as number,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("uwf cas has CLI exit codes", () => {
|
||||||
|
test("exits 0 when hash exists", async () => {
|
||||||
|
// Setup: Create a temp storage root, put a text node, capture hash
|
||||||
|
const putResult = await cmdCasPutText(storageRoot, "test content");
|
||||||
|
const hash = putResult.hash;
|
||||||
|
|
||||||
|
// Execute: uwf cas has <hash>
|
||||||
|
const result = execUwf(["cas", "has", hash]);
|
||||||
|
|
||||||
|
// Assert: stdout contains {"exists":true}, exit code === 0
|
||||||
|
expect(result.stdout).toContain('"exists":true');
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exits 1 when hash does not exist", () => {
|
||||||
|
// Setup: Create a temp storage root (empty CAS store)
|
||||||
|
// Execute: uwf cas has NOSUCHHASH123
|
||||||
|
const result = execUwf(["cas", "has", "NOSUCHHASH123"]);
|
||||||
|
|
||||||
|
// Assert: stdout contains {"exists":false}, exit code === 1
|
||||||
|
expect(result.stdout).toContain('"exists":false');
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("JSON output format unchanged for exists=true", async () => {
|
||||||
|
// Setup: Create store, put node
|
||||||
|
const putResult = await cmdCasPutText(storageRoot, "test");
|
||||||
|
const hash = putResult.hash;
|
||||||
|
|
||||||
|
// Execute: uwf cas has <hash>
|
||||||
|
const result = execUwf(["cas", "has", hash]);
|
||||||
|
|
||||||
|
// Assert: stdout JSON parses correctly to {exists: true}
|
||||||
|
const parsed = JSON.parse(result.stdout.trim());
|
||||||
|
expect(parsed).toEqual({ exists: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("JSON output format unchanged for exists=false", () => {
|
||||||
|
// Setup: Create empty store
|
||||||
|
// Execute: uwf cas has INVALID
|
||||||
|
const result = execUwf(["cas", "has", "INVALID"]);
|
||||||
|
|
||||||
|
// Assert: stdout JSON parses correctly to {exists: false}
|
||||||
|
const parsed = JSON.parse(result.stdout.trim());
|
||||||
|
expect(parsed).toEqual({ exists: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("YAML output format preserves exit code behavior for exists=true", async () => {
|
||||||
|
// Setup: Create store with node
|
||||||
|
const putResult = await cmdCasPutText(storageRoot, "test");
|
||||||
|
const hash = putResult.hash;
|
||||||
|
|
||||||
|
// Execute: uwf --format yaml cas has <hash>
|
||||||
|
const result = execUwf(["--format", "yaml", "cas", "has", hash]);
|
||||||
|
|
||||||
|
// Assert: exit code === 0, output is YAML format
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.stdout).toContain("exists:");
|
||||||
|
expect(result.stdout).toContain("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("YAML output format preserves exit code behavior for exists=false", () => {
|
||||||
|
// Setup: Create empty store
|
||||||
|
// Execute: uwf --format yaml cas has INVALID
|
||||||
|
const result = execUwf(["--format", "yaml", "cas", "has", "INVALID"]);
|
||||||
|
|
||||||
|
// Assert: exit code === 1, output is YAML format
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.stdout).toContain("exists:");
|
||||||
|
expect(result.stdout).toContain("false");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("regression: other cas commands unaffected", () => {
|
||||||
|
test("uwf cas get still exits 1 on not-found with error message", () => {
|
||||||
|
// Execute: uwf cas get NOSUCHHASH
|
||||||
|
const result = execUwf(["cas", "get", "NOSUCHHASH"]);
|
||||||
|
|
||||||
|
// Assert: exit code === 1, stderr contains "Node not found"
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.stderr).toContain("Node not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uwf cas put-text behavior unchanged", () => {
|
||||||
|
// Execute: uwf cas put-text "hello"
|
||||||
|
const result = execUwf(["cas", "put-text", "hello"]);
|
||||||
|
|
||||||
|
// Assert: exit code === 0, returns hash
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
const parsed = JSON.parse(result.stdout.trim());
|
||||||
|
expect(parsed).toHaveProperty("hash");
|
||||||
|
expect(typeof parsed.hash).toBe("string");
|
||||||
|
expect(parsed.hash.length).toBe(13); // Crockford Base32 XXH64 hash length
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { mkdir, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import { cmdCasHas, cmdCasPutText } from "../commands/cas.js";
|
||||||
|
|
||||||
|
let storageRoot: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
storageRoot = join(tmpdir(), `uwf-cas-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||||
|
await mkdir(storageRoot, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(storageRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cmdCasHas", () => {
|
||||||
|
test("returns {exists: true} for existing hash", async () => {
|
||||||
|
// Setup: Create a test store, put a node, get its hash
|
||||||
|
const putResult = await cmdCasPutText(storageRoot, "test content");
|
||||||
|
const hash = putResult.hash;
|
||||||
|
|
||||||
|
// Execute: Call cmdCasHas with the valid hash
|
||||||
|
const result = await cmdCasHas(storageRoot, hash);
|
||||||
|
|
||||||
|
// Assert: Result equals {exists: true}
|
||||||
|
expect(result).toEqual({ exists: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns {exists: false} for non-existent hash", async () => {
|
||||||
|
// Setup: Create an empty test store
|
||||||
|
// (storageRoot already created in beforeEach)
|
||||||
|
|
||||||
|
// Execute: Call cmdCasHas with an invalid hash
|
||||||
|
const result = await cmdCasHas(storageRoot, "INVALIDHASH12");
|
||||||
|
|
||||||
|
// Assert: Result equals {exists: false}
|
||||||
|
expect(result).toEqual({ exists: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not throw for non-existent hash", async () => {
|
||||||
|
// Setup: Create an empty test store
|
||||||
|
// Execute & Assert: Does not throw, returns {exists: false}
|
||||||
|
await expect(cmdCasHas(storageRoot, "NOSUCHHASH123")).resolves.toEqual({
|
||||||
|
exists: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles malformed hash gracefully", async () => {
|
||||||
|
// Setup: Create a test store
|
||||||
|
// Execute: Call cmdCasHas with a too-short hash
|
||||||
|
const result = await cmdCasHas(storageRoot, "xyz");
|
||||||
|
|
||||||
|
// Assert: Returns {exists: false} (store.has() returns false)
|
||||||
|
expect(result).toEqual({ exists: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles empty hash string", async () => {
|
||||||
|
// Execute: Call cmdCasHas with an empty string
|
||||||
|
const result = await cmdCasHas(storageRoot, "");
|
||||||
|
|
||||||
|
// Assert: Returns {exists: false}
|
||||||
|
expect(result).toEqual({ exists: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles hash with special characters", async () => {
|
||||||
|
// Execute: Call cmdCasHas with special characters
|
||||||
|
const result = await cmdCasHas(storageRoot, "HASH!@#");
|
||||||
|
|
||||||
|
// Assert: Returns {exists: false}
|
||||||
|
expect(result).toEqual({ exists: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import { resolveHeadHash } from "../commands/shared.js";
|
||||||
|
import { appendThreadHistory, saveThreadsIndex } from "../store.js";
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-resolve-head-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveHeadHash", () => {
|
||||||
|
test("returns head hash from threads.yaml for active thread", async () => {
|
||||||
|
const threadId = "01JTEST0000000000000000001" as ThreadId;
|
||||||
|
const headHash = "active_hash_123" as CasRef;
|
||||||
|
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
|
||||||
|
|
||||||
|
const result = await resolveHeadHash(tmpDir, threadId);
|
||||||
|
|
||||||
|
expect(result).toBe(headHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to history.jsonl when thread not in threads.yaml", async () => {
|
||||||
|
const threadId = "01JTEST0000000000000000002" as ThreadId;
|
||||||
|
const headHash = "completed_hash_456" as CasRef;
|
||||||
|
const workflowHash = "workflow_hash_789" as CasRef;
|
||||||
|
|
||||||
|
// No entry in threads.yaml, only in history.jsonl
|
||||||
|
await saveThreadsIndex(tmpDir, {});
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: headHash,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resolveHeadHash(tmpDir, threadId);
|
||||||
|
|
||||||
|
expect(result).toBe(headHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: Testing the error case requires CLI-level testing because resolveHeadHash
|
||||||
|
// calls fail() which does process.exit(1), terminating the test runner.
|
||||||
|
// The error behavior is tested in integration tests below via CLI invocation.
|
||||||
|
|
||||||
|
test("prioritizes active thread over history when thread exists in both", async () => {
|
||||||
|
const threadId = "01JTEST0000000000000000004" as ThreadId;
|
||||||
|
const activeHash = "active_hash_v2" as CasRef;
|
||||||
|
const historicalHash = "historical_hash_v1" as CasRef;
|
||||||
|
const workflowHash = "workflow_hash_xyz" as CasRef;
|
||||||
|
|
||||||
|
// Thread exists in both locations (should not happen normally, but test the precedence)
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: activeHash });
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: historicalHash,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resolveHeadHash(tmpDir, threadId);
|
||||||
|
|
||||||
|
// Should return the active head, not the historical one
|
||||||
|
expect(result).toBe(activeHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("finds thread from multiple history entries", async () => {
|
||||||
|
const threadId1 = "01JTEST0000000000000000005" as ThreadId;
|
||||||
|
const threadId2 = "01JTEST0000000000000000006" as ThreadId;
|
||||||
|
const threadId3 = "01JTEST0000000000000000007" as ThreadId;
|
||||||
|
const hash1 = "hash_thread1" as CasRef;
|
||||||
|
const hash2 = "hash_thread2" as CasRef;
|
||||||
|
const hash3 = "hash_thread3" as CasRef;
|
||||||
|
const workflowHash = "workflow_hash_abc" as CasRef;
|
||||||
|
|
||||||
|
await saveThreadsIndex(tmpDir, {});
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId1,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: hash1,
|
||||||
|
completedAt: Date.now() - 2000,
|
||||||
|
});
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId2,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: hash2,
|
||||||
|
completedAt: Date.now() - 1000,
|
||||||
|
});
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId3,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: hash3,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resolveHeadHash(tmpDir, threadId2);
|
||||||
|
|
||||||
|
expect(result).toBe(hash2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test: Issue #474 - tea pr create fails in git worktree directories
|
||||||
|
*
|
||||||
|
* This test verifies that the solve-issue workflow's committer role
|
||||||
|
* includes the --repo flag when running tea pr create, which fixes
|
||||||
|
* the "path segment [0] is empty" error in worktree directories.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("solve-issue workflow: tea pr create worktree fix", () => {
|
||||||
|
// Navigate up from packages/cli-workflow to repo root
|
||||||
|
const workflowPath = join(process.cwd(), "..", "..", ".workflows", "solve-issue.yaml");
|
||||||
|
|
||||||
|
test("committer procedure should include --repo flag in tea pr create command", async () => {
|
||||||
|
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||||
|
const workflow = parse(yamlContent) as WorkflowPayload;
|
||||||
|
|
||||||
|
expect(workflow.roles.committer).toBeDefined();
|
||||||
|
const committerProcedure = workflow.roles.committer?.procedure;
|
||||||
|
expect(committerProcedure).toBeDefined();
|
||||||
|
|
||||||
|
// Verify the procedure includes tea pr create with --repo flag
|
||||||
|
expect(committerProcedure).toContain("tea pr create");
|
||||||
|
expect(committerProcedure).toContain("--repo");
|
||||||
|
|
||||||
|
// Verify the --repo flag appears before or together with tea pr create
|
||||||
|
// This ensures the command is: tea pr create --repo <owner/repo> ...
|
||||||
|
const teaPrCreateMatch = committerProcedure?.match(/tea pr create[^\n]*/);
|
||||||
|
expect(teaPrCreateMatch).not.toBeNull();
|
||||||
|
|
||||||
|
if (teaPrCreateMatch) {
|
||||||
|
const teaCommandLine = teaPrCreateMatch[0];
|
||||||
|
expect(teaCommandLine).toContain("--repo");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("committer procedure should mention repo extraction from git remote", async () => {
|
||||||
|
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||||
|
const workflow = parse(yamlContent) as WorkflowPayload;
|
||||||
|
|
||||||
|
const committerProcedure = workflow.roles.committer?.procedure;
|
||||||
|
expect(committerProcedure).toBeDefined();
|
||||||
|
|
||||||
|
// Verify the procedure mentions extracting repo info from git remote
|
||||||
|
// This ensures fallback logic is documented
|
||||||
|
expect(committerProcedure).toMatch(/git remote/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("committer procedure should include error handling for tea failures", async () => {
|
||||||
|
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||||
|
const workflow = parse(yamlContent) as WorkflowPayload;
|
||||||
|
|
||||||
|
const committerProcedure = workflow.roles.committer?.procedure;
|
||||||
|
expect(committerProcedure).toBeDefined();
|
||||||
|
|
||||||
|
// Verify the procedure includes error handling guidance
|
||||||
|
// This ensures we capture failures and provide actionable output
|
||||||
|
expect(committerProcedure).toMatch(/error|fail/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workflow should be parseable as valid WorkflowPayload", async () => {
|
||||||
|
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||||
|
const workflow = parse(yamlContent) as WorkflowPayload;
|
||||||
|
|
||||||
|
// Basic structure validation
|
||||||
|
expect(workflow.name).toBe("solve-issue");
|
||||||
|
expect(workflow.roles).toBeDefined();
|
||||||
|
expect(workflow.conditions).toBeDefined();
|
||||||
|
expect(workflow.graph).toBeDefined();
|
||||||
|
|
||||||
|
// Verify committer role exists with required fields
|
||||||
|
expect(workflow.roles.committer).toBeDefined();
|
||||||
|
expect(workflow.roles.committer?.description).toBeDefined();
|
||||||
|
expect(workflow.roles.committer?.goal).toBeDefined();
|
||||||
|
expect(workflow.roles.committer?.procedure).toBeDefined();
|
||||||
|
expect(workflow.roles.committer?.output).toBeDefined();
|
||||||
|
expect(workflow.roles.committer?.frontmatter).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("committer frontmatter schema should require success field", async () => {
|
||||||
|
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||||
|
// Parse as any to access the raw YAML structure (frontmatter is inline JSON Schema in YAML)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const workflow = parse(yamlContent) as any;
|
||||||
|
|
||||||
|
const frontmatter = workflow.roles.committer?.frontmatter;
|
||||||
|
expect(frontmatter).toBeDefined();
|
||||||
|
expect(frontmatter?.type).toBe("object");
|
||||||
|
expect(frontmatter?.properties?.success).toBeDefined();
|
||||||
|
expect(frontmatter?.properties?.success?.type).toBe("boolean");
|
||||||
|
expect(frontmatter?.required).toContain("success");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,519 @@
|
|||||||
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { bootstrap, putSchema } from "@uncaged/json-cas";
|
||||||
|
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||||
|
import type { CasRef } from "@uncaged/workflow-protocol";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import { cmdStepRead } from "../commands/step.js";
|
||||||
|
import { registerUwfSchemas } from "../schemas.js";
|
||||||
|
|
||||||
|
// ── schemas used in tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TURN_SCHEMA = {
|
||||||
|
title: "hermes-turn",
|
||||||
|
type: "object" as const,
|
||||||
|
required: ["index", "role", "content"],
|
||||||
|
properties: {
|
||||||
|
index: { type: "integer" as const },
|
||||||
|
role: { type: "string" as const },
|
||||||
|
content: { type: "string" as const },
|
||||||
|
toolCalls: {
|
||||||
|
anyOf: [
|
||||||
|
{ type: "array" as const, items: { type: "object" as const } },
|
||||||
|
{ type: "null" as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
reasoning: { anyOf: [{ type: "string" as const }, { type: "null" as const }] },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DETAIL_SCHEMA = {
|
||||||
|
title: "hermes-detail",
|
||||||
|
type: "object" as const,
|
||||||
|
required: ["sessionId", "model", "duration", "turnCount", "turns"],
|
||||||
|
properties: {
|
||||||
|
sessionId: { type: "string" as const },
|
||||||
|
model: { type: "string" as const },
|
||||||
|
duration: { type: "integer" as const },
|
||||||
|
turnCount: { type: "integer" as const },
|
||||||
|
turns: {
|
||||||
|
type: "array" as const,
|
||||||
|
items: { type: "string" as const, format: "cas_ref" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function registerDetailSchemas(store: ReturnType<typeof createFsStore>) {
|
||||||
|
await bootstrap(store);
|
||||||
|
const [turn, detail] = await Promise.all([
|
||||||
|
putSchema(store, TURN_SCHEMA),
|
||||||
|
putSchema(store, DETAIL_SCHEMA),
|
||||||
|
]);
|
||||||
|
return { turn, detail };
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateContent(size: number, prefix = "Content"): string {
|
||||||
|
const base = `${prefix} `;
|
||||||
|
const repeat = Math.ceil(size / base.length);
|
||||||
|
return base.repeat(repeat).slice(0, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fixture ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-step-read-test-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── step read tests ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("step read", () => {
|
||||||
|
test("test 1: basic single-step read with 3 turns", async () => {
|
||||||
|
const casDir = join(tmpDir, "cas");
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
const store = createFsStore(casDir);
|
||||||
|
const schemas = await registerUwfSchemas(store);
|
||||||
|
const detailSchemas = await registerDetailSchemas(store);
|
||||||
|
|
||||||
|
const workflowHash = await store.put(schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
worker: {
|
||||||
|
description: "Worker",
|
||||||
|
goal: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do the work.",
|
||||||
|
output: "Summarize the work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Test task",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 3 turns
|
||||||
|
const turnHashes: CasRef[] = [];
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
const content = `Turn ${i} content with some text to make it readable.`;
|
||||||
|
const turnHash = await store.put(detailSchemas.turn, {
|
||||||
|
index: i - 1,
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
turnHashes.push(turnHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "session-1",
|
||||||
|
model: "test-model",
|
||||||
|
duration: 1000,
|
||||||
|
turnCount: 3,
|
||||||
|
turns: turnHashes,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read step with large quota
|
||||||
|
const markdown = await cmdStepRead(tmpDir, stepHash, 10000);
|
||||||
|
|
||||||
|
// Assert structure
|
||||||
|
expect(markdown).toContain(`# Step ${stepHash}`);
|
||||||
|
expect(markdown).toContain("**Role:** worker");
|
||||||
|
expect(markdown).toContain("**Agent:** uwf-test");
|
||||||
|
expect(markdown).toContain("## Turn 1");
|
||||||
|
expect(markdown).toContain("## Turn 2");
|
||||||
|
expect(markdown).toContain("## Turn 3");
|
||||||
|
expect(markdown).toContain("Turn 1 content with some text to make it readable.");
|
||||||
|
expect(markdown).toContain("Turn 2 content with some text to make it readable.");
|
||||||
|
expect(markdown).toContain("Turn 3 content with some text to make it readable.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("test 2: quota enforcement - multiple turns", async () => {
|
||||||
|
const casDir = join(tmpDir, "cas");
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
const store = createFsStore(casDir);
|
||||||
|
const schemas = await registerUwfSchemas(store);
|
||||||
|
const detailSchemas = await registerDetailSchemas(store);
|
||||||
|
|
||||||
|
const workflowHash = await store.put(schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
worker: {
|
||||||
|
description: "Worker",
|
||||||
|
goal: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do the work.",
|
||||||
|
output: "Summarize the work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Test task",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 4 turns of ~300 chars each
|
||||||
|
const turnHashes: CasRef[] = [];
|
||||||
|
for (let i = 1; i <= 4; i++) {
|
||||||
|
const content = generateContent(300, `Turn${i}`);
|
||||||
|
const turnHash = await store.put(detailSchemas.turn, {
|
||||||
|
index: i - 1,
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
turnHashes.push(turnHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "session-1",
|
||||||
|
model: "test-model",
|
||||||
|
duration: 1000,
|
||||||
|
turnCount: 4,
|
||||||
|
turns: turnHashes,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read step with limited quota (700 chars)
|
||||||
|
const markdown = await cmdStepRead(tmpDir, stepHash, 700);
|
||||||
|
|
||||||
|
// Assert only most recent turns fit
|
||||||
|
expect(markdown).toContain(`# Step ${stepHash}`);
|
||||||
|
// Should have skip hint
|
||||||
|
expect(markdown).toContain("Earlier turns omitted");
|
||||||
|
// Should include at least Turn 4 (most recent)
|
||||||
|
expect(markdown).toContain("Turn4");
|
||||||
|
// Total length should respect quota (with tolerance for structural overhead)
|
||||||
|
expect(markdown.length).toBeLessThanOrEqual(900); // 700 quota + 200 buffer tolerance
|
||||||
|
});
|
||||||
|
|
||||||
|
test("test 3: minimal quota edge case - always show at least one turn", async () => {
|
||||||
|
const casDir = join(tmpDir, "cas");
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
const store = createFsStore(casDir);
|
||||||
|
const schemas = await registerUwfSchemas(store);
|
||||||
|
const detailSchemas = await registerDetailSchemas(store);
|
||||||
|
|
||||||
|
const workflowHash = await store.put(schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
worker: {
|
||||||
|
description: "Worker",
|
||||||
|
goal: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do the work.",
|
||||||
|
output: "Summarize the work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Test task",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 1 turn of 500 chars
|
||||||
|
const content = generateContent(500, "LongTurn");
|
||||||
|
const turnHash = await store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const detailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "session-1",
|
||||||
|
model: "test-model",
|
||||||
|
duration: 1000,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read step with minimal quota (1 char)
|
||||||
|
const markdown = await cmdStepRead(tmpDir, stepHash, 1);
|
||||||
|
|
||||||
|
// Assert at least one turn is always shown
|
||||||
|
expect(markdown).toContain("LongTurn");
|
||||||
|
expect(markdown.length).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("test 4: step with no detail field", async () => {
|
||||||
|
const casDir = join(tmpDir, "cas");
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
const store = createFsStore(casDir);
|
||||||
|
const schemas = await registerUwfSchemas(store);
|
||||||
|
|
||||||
|
const workflowHash = await store.put(schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
worker: {
|
||||||
|
description: "Worker",
|
||||||
|
goal: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do the work.",
|
||||||
|
output: "Summarize the work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Test task",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read step - should return metadata only (no error)
|
||||||
|
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
|
||||||
|
|
||||||
|
// Assert metadata is present
|
||||||
|
expect(markdown).toContain(`# Step ${stepHash}`);
|
||||||
|
expect(markdown).toContain("**Role:** worker");
|
||||||
|
expect(markdown).toContain("**Agent:** uwf-test");
|
||||||
|
// Should not have turn sections
|
||||||
|
expect(markdown).not.toContain("## Turn");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("test 5: step with detail but no turns array", async () => {
|
||||||
|
const casDir = join(tmpDir, "cas");
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
const store = createFsStore(casDir);
|
||||||
|
const schemas = await registerUwfSchemas(store);
|
||||||
|
await registerDetailSchemas(store);
|
||||||
|
|
||||||
|
const workflowHash = await store.put(schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
worker: {
|
||||||
|
description: "Worker",
|
||||||
|
goal: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do the work.",
|
||||||
|
output: "Summarize the work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Test task",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create detail with different schema (no turns)
|
||||||
|
const SIMPLE_DETAIL_SCHEMA = {
|
||||||
|
title: "simple-detail",
|
||||||
|
type: "object" as const,
|
||||||
|
required: ["sessionId"],
|
||||||
|
properties: {
|
||||||
|
sessionId: { type: "string" as const },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await bootstrap(store);
|
||||||
|
const simpleDetailType = await putSchema(store, SIMPLE_DETAIL_SCHEMA);
|
||||||
|
const detailHash = await store.put(simpleDetailType, {
|
||||||
|
sessionId: "session-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read step - should return metadata only (no error)
|
||||||
|
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
|
||||||
|
|
||||||
|
// Assert metadata is present
|
||||||
|
expect(markdown).toContain(`# Step ${stepHash}`);
|
||||||
|
expect(markdown).toContain("**Role:** worker");
|
||||||
|
// Should not have turn sections
|
||||||
|
expect(markdown).not.toContain("## Turn");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("test 6: turn content with special characters", async () => {
|
||||||
|
const casDir = join(tmpDir, "cas");
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
const store = createFsStore(casDir);
|
||||||
|
const schemas = await registerUwfSchemas(store);
|
||||||
|
const detailSchemas = await registerDetailSchemas(store);
|
||||||
|
|
||||||
|
const workflowHash = await store.put(schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
worker: {
|
||||||
|
description: "Worker",
|
||||||
|
goal: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do the work.",
|
||||||
|
output: "Summarize the work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Test task",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create turn with special markdown characters
|
||||||
|
const content = "This has `backticks`, **bold**, *italic*, and [links](http://example.com)";
|
||||||
|
const turnHash = await store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const detailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "session-1",
|
||||||
|
model: "test-model",
|
||||||
|
duration: 1000,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read step
|
||||||
|
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
|
||||||
|
|
||||||
|
// Assert content is rendered correctly without corruption
|
||||||
|
expect(markdown).toContain("`backticks`");
|
||||||
|
expect(markdown).toContain("**bold**");
|
||||||
|
expect(markdown).toContain("*italic*");
|
||||||
|
expect(markdown).toContain("[links](http://example.com)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,550 @@
|
|||||||
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||||
|
import { extractUlidTimestamp, generateUlid } from "@uncaged/workflow-util";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import { createMarker, deleteMarker } from "../background/index.js";
|
||||||
|
import { cmdThreadList } from "../commands/thread.js";
|
||||||
|
import { parseTimeInput } from "../commands/thread-time-parser.js";
|
||||||
|
import type { UwfStore } from "../store.js";
|
||||||
|
import { appendThreadHistory, createUwfStore, saveThreadsIndex } from "../store.js";
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
|
||||||
|
const casDir = join(storageRoot, "cas");
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
return createUwfStore(storageRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTestWorkflow(uwf: UwfStore): Promise<CasRef> {
|
||||||
|
const workflowPayload = {
|
||||||
|
name: "test-workflow",
|
||||||
|
roles: {
|
||||||
|
role1: {
|
||||||
|
goal: "test goal",
|
||||||
|
outputSchema: { type: "object" as const, properties: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
graph: { start: "role1" },
|
||||||
|
conditions: {},
|
||||||
|
};
|
||||||
|
return await uwf.store.put(uwf.schemas.workflow, workflowPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTestThread(
|
||||||
|
uwf: UwfStore,
|
||||||
|
storageRoot: string,
|
||||||
|
workflowHash: CasRef,
|
||||||
|
timestamp: number,
|
||||||
|
): Promise<ThreadId> {
|
||||||
|
const threadId = generateUlid(timestamp) as ThreadId;
|
||||||
|
const startPayload = {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "test prompt",
|
||||||
|
};
|
||||||
|
const headHash = await uwf.store.put(uwf.schemas.startNode, startPayload);
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
|
||||||
|
index[threadId] = headHash;
|
||||||
|
await saveThreadsIndex(storageRoot, index);
|
||||||
|
return threadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markThreadRunning(storageRoot: string, threadId: ThreadId, workflow: CasRef) {
|
||||||
|
await createMarker(storageRoot, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow,
|
||||||
|
pid: process.pid, // Use current process PID so isPidAlive returns true
|
||||||
|
startedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeThread(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: ThreadId,
|
||||||
|
workflowHash: CasRef,
|
||||||
|
headHash: CasRef,
|
||||||
|
) {
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
|
||||||
|
delete index[threadId];
|
||||||
|
await saveThreadsIndex(storageRoot, index);
|
||||||
|
await appendThreadHistory(storageRoot, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: headHash,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── test setup ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = await mkdtemp(join(tmpdir(), "thread-list-filters-test-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── status filter tests ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("cmdThreadList status filter", () => {
|
||||||
|
test("should return idle and running threads when status=active", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
|
||||||
|
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
|
||||||
|
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
|
||||||
|
|
||||||
|
await markThreadRunning(tmpDir, thread2, workflowHash);
|
||||||
|
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||||
|
const thread3Head = index[thread3];
|
||||||
|
if (thread3Head === undefined) throw new Error("thread3 head not found");
|
||||||
|
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, ["idle", "running"], null, null, null, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread2].sort());
|
||||||
|
|
||||||
|
// Clean up marker after test
|
||||||
|
await deleteMarker(tmpDir, thread2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should support comma-separated status values", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
|
||||||
|
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
|
||||||
|
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
|
||||||
|
|
||||||
|
await markThreadRunning(tmpDir, thread2, workflowHash);
|
||||||
|
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||||
|
const thread3Head = index[thread3];
|
||||||
|
if (thread3Head === undefined) throw new Error("thread3 head not found");
|
||||||
|
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, ["idle", "completed"], null, null, null, null);
|
||||||
|
|
||||||
|
// Clean up marker
|
||||||
|
await deleteMarker(tmpDir, thread2);
|
||||||
|
|
||||||
|
// thread2 is running (not idle), so should not be included
|
||||||
|
// Expected: thread1 (idle) and thread3 (completed)
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread3].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should support single status filter (backward compat)", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const _thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
|
||||||
|
const _thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
|
||||||
|
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
|
||||||
|
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||||
|
const thread3Head = index[thread3];
|
||||||
|
if (thread3Head === undefined) throw new Error("thread3 head not found");
|
||||||
|
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, ["completed"], null, null, null, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.thread).toBe(thread3);
|
||||||
|
expect(result[0]?.status).toBe("completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return all threads when no status filter provided", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
|
||||||
|
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
|
||||||
|
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
|
||||||
|
|
||||||
|
await markThreadRunning(tmpDir, thread2, workflowHash);
|
||||||
|
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||||
|
const thread3Head = index[thread3];
|
||||||
|
if (thread3Head === undefined) throw new Error("thread3 head not found");
|
||||||
|
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, null, null, null, null, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread2, thread3].sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── time range filtering tests ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("cmdThreadList time filters", () => {
|
||||||
|
test("should filter threads created after given timestamp", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const ts1 = Date.UTC(2026, 4, 20, 0, 0, 0);
|
||||||
|
const ts2 = Date.UTC(2026, 4, 21, 0, 0, 0);
|
||||||
|
const ts3 = Date.UTC(2026, 4, 22, 0, 0, 0);
|
||||||
|
|
||||||
|
const _threadA = await createTestThread(uwf, tmpDir, workflowHash, ts1);
|
||||||
|
const threadB = await createTestThread(uwf, tmpDir, workflowHash, ts2);
|
||||||
|
const threadC = await createTestThread(uwf, tmpDir, workflowHash, ts3);
|
||||||
|
|
||||||
|
// Use a timestamp slightly before ts2 to include threadB
|
||||||
|
const afterMs = Date.UTC(2026, 4, 20, 12, 0, 0);
|
||||||
|
const result = await cmdThreadList(tmpDir, null, afterMs, null, null, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((r) => r.thread).sort()).toEqual([threadB, threadC].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should filter threads created before given timestamp", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const ts1 = Date.UTC(2026, 4, 20, 0, 0, 0);
|
||||||
|
const ts2 = Date.UTC(2026, 4, 21, 0, 0, 0);
|
||||||
|
const ts3 = Date.UTC(2026, 4, 22, 0, 0, 0);
|
||||||
|
|
||||||
|
const threadA = await createTestThread(uwf, tmpDir, workflowHash, ts1);
|
||||||
|
const threadB = await createTestThread(uwf, tmpDir, workflowHash, ts2);
|
||||||
|
const _threadC = await createTestThread(uwf, tmpDir, workflowHash, ts3);
|
||||||
|
|
||||||
|
const beforeMs = Date.UTC(2026, 4, 22, 0, 0, 0);
|
||||||
|
const result = await cmdThreadList(tmpDir, null, null, beforeMs, null, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((r) => r.thread).sort()).toEqual([threadA, threadB].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should support both after and before filters (time range)", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const ts1 = Date.UTC(2026, 4, 20, 0, 0, 0);
|
||||||
|
const ts2 = Date.UTC(2026, 4, 21, 0, 0, 0);
|
||||||
|
const ts3 = Date.UTC(2026, 4, 22, 0, 0, 0);
|
||||||
|
|
||||||
|
const _threadA = await createTestThread(uwf, tmpDir, workflowHash, ts1);
|
||||||
|
const threadB = await createTestThread(uwf, tmpDir, workflowHash, ts2);
|
||||||
|
const _threadC = await createTestThread(uwf, tmpDir, workflowHash, ts3);
|
||||||
|
|
||||||
|
const afterMs = Date.UTC(2026, 4, 20, 12, 0, 0);
|
||||||
|
const beforeMs = Date.UTC(2026, 4, 22, 0, 0, 0);
|
||||||
|
const result = await cmdThreadList(tmpDir, null, afterMs, beforeMs, null, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.thread).toBe(threadB);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── pagination tests ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("cmdThreadList pagination", () => {
|
||||||
|
test("should limit results with --take", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const threads: ThreadId[] = [];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
threads.push(await createTestThread(uwf, tmpDir, workflowHash, Date.now() - i * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, null, null, null, null, 5);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should skip first N threads with --skip", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const threads: ThreadId[] = [];
|
||||||
|
// Create threads in chronological order, but they'll be sorted newest first
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
threads.push(await createTestThread(uwf, tmpDir, workflowHash, Date.now() + i * 100));
|
||||||
|
// Small delay to ensure distinct timestamps
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, null, null, null, 3, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(7);
|
||||||
|
// The 3 newest threads should be skipped, so we should get the 7 oldest
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should support skip + take for pagination", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const threads: ThreadId[] = [];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
threads.push(await createTestThread(uwf, tmpDir, workflowHash, Date.now() + i * 100));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, null, null, null, 5, 3);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
// Should skip first 5 (newest), then take 3
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle take > available threads", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const _thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
|
||||||
|
const _thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
|
||||||
|
const _thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, null, null, null, null, 10);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return empty array when skip >= thread count", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
|
||||||
|
await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
|
||||||
|
await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, null, null, null, 5, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── combined filters tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("combined filters", () => {
|
||||||
|
test("should combine status and time range filters", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const ts1 = Date.UTC(2026, 4, 20, 0, 0, 0);
|
||||||
|
const ts2 = Date.UTC(2026, 4, 21, 0, 0, 0);
|
||||||
|
const ts3 = Date.UTC(2026, 4, 22, 0, 0, 0);
|
||||||
|
const ts4 = Date.UTC(2026, 4, 23, 0, 0, 0);
|
||||||
|
|
||||||
|
const _thread1 = await createTestThread(uwf, tmpDir, workflowHash, ts1);
|
||||||
|
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, ts2);
|
||||||
|
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, ts3);
|
||||||
|
const thread4 = await createTestThread(uwf, tmpDir, workflowHash, ts4);
|
||||||
|
|
||||||
|
await markThreadRunning(tmpDir, thread2, workflowHash);
|
||||||
|
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||||
|
const thread3Head = index[thread3];
|
||||||
|
if (thread3Head === undefined) throw new Error("thread3 head not found");
|
||||||
|
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
|
||||||
|
|
||||||
|
const afterMs = Date.UTC(2026, 4, 20, 12, 0, 0);
|
||||||
|
const result = await cmdThreadList(tmpDir, ["idle"], afterMs, null, null, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.thread).toBe(thread4);
|
||||||
|
expect(result[0]?.status).toBe("idle");
|
||||||
|
|
||||||
|
// Clean up marker
|
||||||
|
await deleteMarker(tmpDir, thread2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should combine status filter and pagination", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const threads: ThreadId[] = [];
|
||||||
|
for (let i = 9; i >= 0; i--) {
|
||||||
|
const thread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() + i * 1000);
|
||||||
|
threads.push(thread);
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||||
|
const headHash = index[thread];
|
||||||
|
if (headHash === undefined) throw new Error("head not found");
|
||||||
|
await completeThread(tmpDir, thread, workflowHash, headHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await cmdThreadList(tmpDir, ["completed"], null, null, 3, 5);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(5);
|
||||||
|
for (const r of result) {
|
||||||
|
expect(r.status).toBe("completed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should combine time range and pagination", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const threads: ThreadId[] = [];
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const ts = Date.UTC(2026, 4, 1 + i, 0, 0, 0);
|
||||||
|
threads.push(await createTestThread(uwf, tmpDir, workflowHash, ts));
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterMs = Date.UTC(2026, 4, 10, 0, 0, 0);
|
||||||
|
const result = await cmdThreadList(tmpDir, null, afterMs, null, 2, 5);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(5);
|
||||||
|
for (const r of result) {
|
||||||
|
const ts = extractUlidTimestamp(r.thread);
|
||||||
|
expect(ts).not.toBeNull();
|
||||||
|
if (ts !== null) {
|
||||||
|
expect(ts).toBeGreaterThan(afterMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setupMixedStatusThreads(
|
||||||
|
uwf: UwfStore,
|
||||||
|
workflowHash: string,
|
||||||
|
count: number,
|
||||||
|
): Promise<ThreadId[]> {
|
||||||
|
const threads: ThreadId[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const ts = Date.UTC(2026, 4, 10 + i, 0, 0, 0);
|
||||||
|
const thread = await createTestThread(uwf, tmpDir, workflowHash, ts);
|
||||||
|
threads.push(thread);
|
||||||
|
|
||||||
|
if (i % 2 === 0) {
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||||
|
const headHash = index[thread];
|
||||||
|
if (headHash === undefined) throw new Error("head not found");
|
||||||
|
await completeThread(tmpDir, thread, workflowHash, headHash);
|
||||||
|
} else {
|
||||||
|
await markThreadRunning(tmpDir, thread, workflowHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return threads;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupRunningMarkers(threads: ThreadId[]): Promise<void> {
|
||||||
|
for (let i = 0; i < threads.length; i++) {
|
||||||
|
if (i % 2 !== 0) {
|
||||||
|
await deleteMarker(tmpDir, threads[i] as ThreadId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("should combine all filters (status + time + pagination)", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
const threads = await setupMixedStatusThreads(uwf, workflowHash, 15);
|
||||||
|
|
||||||
|
const afterMs = Date.UTC(2026, 4, 14, 12, 0, 0);
|
||||||
|
const beforeMs = Date.UTC(2026, 4, 20, 0, 0, 0);
|
||||||
|
const result = await cmdThreadList(tmpDir, ["idle", "running"], afterMs, beforeMs, 1, 3);
|
||||||
|
|
||||||
|
expect(result.length).toBeLessThanOrEqual(3);
|
||||||
|
for (const r of result) {
|
||||||
|
expect(["idle", "running"]).toContain(r.status);
|
||||||
|
const ts = extractUlidTimestamp(r.thread);
|
||||||
|
if (ts !== null) {
|
||||||
|
expect(ts).toBeGreaterThan(afterMs);
|
||||||
|
expect(ts).toBeLessThan(beforeMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await cleanupRunningMarkers(threads);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── edge cases tests ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
test("should handle empty thread list", async () => {
|
||||||
|
await makeUwfStore(tmpDir);
|
||||||
|
const result = await cmdThreadList(tmpDir, null, null, null, null, null);
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should skip threads with invalid ULID when time filtering", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const workflowHash = await createTestWorkflow(uwf);
|
||||||
|
|
||||||
|
const thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
|
||||||
|
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
|
||||||
|
|
||||||
|
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
|
||||||
|
index["INVALID_ULID_FORMAT_HERE" as ThreadId] = "01J6HMVRNQKJV2";
|
||||||
|
await saveThreadsIndex(tmpDir, index);
|
||||||
|
|
||||||
|
const afterMs = Date.now() - 3000;
|
||||||
|
const result = await cmdThreadList(tmpDir, null, afterMs, null, null, null);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread2].sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── time parsing tests ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("relative time parsing", () => {
|
||||||
|
test("should parse '7d' as 7 days ago", () => {
|
||||||
|
const nowMs = Date.UTC(2026, 4, 24, 12, 0, 0);
|
||||||
|
const result = parseTimeInput("7d", nowMs);
|
||||||
|
const expected = Date.UTC(2026, 4, 17, 12, 0, 0);
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should parse '24h' as 24 hours ago", () => {
|
||||||
|
const nowMs = Date.UTC(2026, 4, 24, 12, 0, 0);
|
||||||
|
const result = parseTimeInput("24h", nowMs);
|
||||||
|
const expected = Date.UTC(2026, 4, 23, 12, 0, 0);
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should parse '30m' as 30 minutes ago", () => {
|
||||||
|
const nowMs = Date.UTC(2026, 4, 24, 12, 30, 0);
|
||||||
|
const result = parseTimeInput("30m", nowMs);
|
||||||
|
const expected = Date.UTC(2026, 4, 24, 12, 0, 0);
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should parse '1d' as 1 day ago", () => {
|
||||||
|
const nowMs = Date.UTC(2026, 4, 24, 0, 0, 0);
|
||||||
|
const result = parseTimeInput("1d", nowMs);
|
||||||
|
const expected = Date.UTC(2026, 4, 23, 0, 0, 0);
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ISO date parsing", () => {
|
||||||
|
test("should parse ISO date (YYYY-MM-DD)", () => {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const result = parseTimeInput("2026-05-20", nowMs);
|
||||||
|
const expected = Date.UTC(2026, 4, 20, 0, 0, 0);
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should parse ISO datetime (YYYY-MM-DDTHH:MM:SS)", () => {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const result = parseTimeInput("2026-05-20T14:30:00", nowMs);
|
||||||
|
const expected = Date.parse("2026-05-20T14:30:00");
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should parse ISO datetime with Z suffix", () => {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const result = parseTimeInput("2026-05-20T14:30:00Z", nowMs);
|
||||||
|
const expected = Date.UTC(2026, 4, 20, 14, 30, 0);
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject invalid date formats", () => {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
expect(() => parseTimeInput("not-a-date", nowMs)).toThrow();
|
||||||
|
expect(() => parseTimeInput("2026-13-01", nowMs)).toThrow();
|
||||||
|
expect(() => parseTimeInput("invalid", nowMs)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,583 @@
|
|||||||
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { bootstrap, putSchema } from "@uncaged/json-cas";
|
||||||
|
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||||
|
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import { cmdThreadRead } from "../commands/thread.js";
|
||||||
|
import { registerUwfSchemas } from "../schemas.js";
|
||||||
|
import { saveThreadsIndex } from "../store.js";
|
||||||
|
|
||||||
|
// ── schemas used in tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TURN_SCHEMA = {
|
||||||
|
title: "hermes-turn",
|
||||||
|
type: "object" as const,
|
||||||
|
required: ["index", "role", "content"],
|
||||||
|
properties: {
|
||||||
|
index: { type: "integer" as const },
|
||||||
|
role: { type: "string" as const },
|
||||||
|
content: { type: "string" as const },
|
||||||
|
toolCalls: {
|
||||||
|
anyOf: [
|
||||||
|
{ type: "array" as const, items: { type: "object" as const } },
|
||||||
|
{ type: "null" as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
reasoning: { anyOf: [{ type: "string" as const }, { type: "null" as const }] },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DETAIL_SCHEMA = {
|
||||||
|
title: "hermes-detail",
|
||||||
|
type: "object" as const,
|
||||||
|
required: ["sessionId", "model", "duration", "turnCount", "turns"],
|
||||||
|
properties: {
|
||||||
|
sessionId: { type: "string" as const },
|
||||||
|
model: { type: "string" as const },
|
||||||
|
duration: { type: "integer" as const },
|
||||||
|
turnCount: { type: "integer" as const },
|
||||||
|
turns: {
|
||||||
|
type: "array" as const,
|
||||||
|
items: { type: "string" as const, format: "cas_ref" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function registerDetailSchemas(store: ReturnType<typeof createFsStore>) {
|
||||||
|
await bootstrap(store);
|
||||||
|
const [turn, detail] = await Promise.all([
|
||||||
|
putSchema(store, TURN_SCHEMA),
|
||||||
|
putSchema(store, DETAIL_SCHEMA),
|
||||||
|
]);
|
||||||
|
return { turn, detail };
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateContent(size: number, prefix = "Content"): string {
|
||||||
|
const base = `${prefix} `;
|
||||||
|
const repeat = Math.ceil(size / base.length);
|
||||||
|
return base.repeat(repeat).slice(0, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fixture ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-quota-test-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── thread read quota enforcement ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("thread read --quota flag", () => {
|
||||||
|
test("test 1: basic quota enforcement with 3 steps", async () => {
|
||||||
|
const casDir = join(tmpDir, "cas");
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
const store = createFsStore(casDir);
|
||||||
|
const schemas = await registerUwfSchemas(store);
|
||||||
|
const detailSchemas = await registerDetailSchemas(store);
|
||||||
|
|
||||||
|
const workflowHash = await store.put(schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
worker: {
|
||||||
|
description: "Worker",
|
||||||
|
goal: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do the work.",
|
||||||
|
output: "Summarize the work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Test task",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 3 steps with ~500 chars each
|
||||||
|
const steps: CasRef[] = [];
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
const content = generateContent(500, `Step${i}`);
|
||||||
|
const turnHash = await store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const detailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: `session-${i}`,
|
||||||
|
model: "test-model",
|
||||||
|
duration: 1000,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: steps[i - 2] ?? null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
steps.push(stepHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadId = "01HX2Q3R4S5T6V7W8X9YZ0" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: steps[2] as CasRef });
|
||||||
|
|
||||||
|
// Set quota to 800 chars - should only fit most recent steps
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, 800, null, false);
|
||||||
|
|
||||||
|
// Quota must be reasonably enforced (allow ~200 char tolerance for skip hint)
|
||||||
|
expect(markdown.length).toBeLessThanOrEqual(1000);
|
||||||
|
|
||||||
|
// Should contain skip hint since not all steps fit
|
||||||
|
expect(markdown).toMatch(/earlier step/);
|
||||||
|
|
||||||
|
// Most recent step should be included
|
||||||
|
expect(markdown).toMatch(/Step3/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("test 2: quota check order - verifies bug is fixed", async () => {
|
||||||
|
const casDir = join(tmpDir, "cas");
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
const store = createFsStore(casDir);
|
||||||
|
const schemas = await registerUwfSchemas(store);
|
||||||
|
const detailSchemas = await registerDetailSchemas(store);
|
||||||
|
|
||||||
|
const workflowHash = await store.put(schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
worker: {
|
||||||
|
description: "Worker",
|
||||||
|
goal: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do the work.",
|
||||||
|
output: "Summarize the work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Test task",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 2 steps: first=300 chars, second=600 chars
|
||||||
|
const step1Content = generateContent(300, "First");
|
||||||
|
const step1TurnHash = await store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content: step1Content,
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const step1DetailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "session-1",
|
||||||
|
model: "test-model",
|
||||||
|
duration: 1000,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [step1TurnHash],
|
||||||
|
});
|
||||||
|
const step1Hash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: step1DetailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const step2Content = generateContent(600, "Second");
|
||||||
|
const step2TurnHash = await store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content: step2Content,
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const step2DetailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "session-2",
|
||||||
|
model: "test-model",
|
||||||
|
duration: 1000,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [step2TurnHash],
|
||||||
|
});
|
||||||
|
const step2Hash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: step1Hash,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: step2DetailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01HX2Q3R4S5T6V7W8X9YZ1" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: step2Hash });
|
||||||
|
|
||||||
|
// Set quota to 500 chars
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, 500, null, false);
|
||||||
|
|
||||||
|
// Bug fix verification: output must be limited (allow ~200 char tolerance)
|
||||||
|
expect(markdown.length).toBeLessThanOrEqual(1100);
|
||||||
|
|
||||||
|
// Should contain "Second" (most recent step)
|
||||||
|
expect(markdown).toMatch(/Second/);
|
||||||
|
|
||||||
|
// Should skip first step
|
||||||
|
expect(markdown).toMatch(/earlier step/);
|
||||||
|
|
||||||
|
// Verify improvement: before fix would be ~1264, now should be much closer to 500
|
||||||
|
expect(markdown.length).toBeLessThan(1200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("test 3: quota with --start section", async () => {
|
||||||
|
const casDir = join(tmpDir, "cas");
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
const store = createFsStore(casDir);
|
||||||
|
const schemas = await registerUwfSchemas(store);
|
||||||
|
const detailSchemas = await registerDetailSchemas(store);
|
||||||
|
|
||||||
|
const workflowHash = await store.put(schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
worker: {
|
||||||
|
description: "Worker",
|
||||||
|
goal: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do the work.",
|
||||||
|
output: "Summarize the work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Test task with a moderately long prompt to test quota accounting",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 2 steps
|
||||||
|
const steps: CasRef[] = [];
|
||||||
|
for (let i = 1; i <= 2; i++) {
|
||||||
|
const content = generateContent(400, `Step${i}`);
|
||||||
|
const turnHash = await store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const detailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: `session-${i}`,
|
||||||
|
model: "test-model",
|
||||||
|
duration: 1000,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: steps[i - 2] ?? null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
steps.push(stepHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadId = "01HX2Q3R4S5T6V7W8X9YZ2" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: steps[1] as CasRef });
|
||||||
|
|
||||||
|
// Set tight quota with --start flag
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, 600, null, true);
|
||||||
|
|
||||||
|
// Quota must be reasonably enforced (allow ~210 char tolerance for structure)
|
||||||
|
expect(markdown.length).toBeLessThanOrEqual(810);
|
||||||
|
|
||||||
|
// Should contain thread header
|
||||||
|
expect(markdown).toMatch(/# Thread/);
|
||||||
|
expect(markdown).toMatch(/test-wf/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("test 5a: quota edge case - minimal quota", async () => {
|
||||||
|
const casDir = join(tmpDir, "cas");
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
const store = createFsStore(casDir);
|
||||||
|
const schemas = await registerUwfSchemas(store);
|
||||||
|
const detailSchemas = await registerDetailSchemas(store);
|
||||||
|
|
||||||
|
const workflowHash = await store.put(schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
worker: {
|
||||||
|
description: "Worker",
|
||||||
|
goal: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do the work.",
|
||||||
|
output: "Summarize the work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Test task",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = generateContent(500, "Test");
|
||||||
|
const turnHash = await store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const detailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "session-1",
|
||||||
|
model: "test-model",
|
||||||
|
duration: 1000,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01HX2Q3R4S5T6V7W8X9YZ4" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||||
|
|
||||||
|
// Minimal quota
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, 1, null, false);
|
||||||
|
|
||||||
|
// Should handle gracefully - always shows at least one step
|
||||||
|
expect(markdown.length).toBeGreaterThan(1);
|
||||||
|
expect(markdown).toMatch(/Test/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("test 5b: quota edge case - very large quota", async () => {
|
||||||
|
const casDir = join(tmpDir, "cas");
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
const store = createFsStore(casDir);
|
||||||
|
const schemas = await registerUwfSchemas(store);
|
||||||
|
const detailSchemas = await registerDetailSchemas(store);
|
||||||
|
|
||||||
|
const workflowHash = await store.put(schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
worker: {
|
||||||
|
description: "Worker",
|
||||||
|
goal: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do the work.",
|
||||||
|
output: "Summarize the work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Test task",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 3 steps
|
||||||
|
const steps: CasRef[] = [];
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
const content = generateContent(300, `Step${i}`);
|
||||||
|
const turnHash = await store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const detailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: `session-${i}`,
|
||||||
|
model: "test-model",
|
||||||
|
duration: 1000,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: steps[i - 2] ?? null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
steps.push(stepHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadId = "01HX2Q3R4S5T6V7W8X9YZ5" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: steps[2] as CasRef });
|
||||||
|
|
||||||
|
// Very large quota
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, 1000000, null, false);
|
||||||
|
|
||||||
|
// Should show all steps (no skipping)
|
||||||
|
expect(markdown).not.toMatch(/earlier step/);
|
||||||
|
expect(markdown).toMatch(/Step1/);
|
||||||
|
expect(markdown).toMatch(/Step2/);
|
||||||
|
expect(markdown).toMatch(/Step3/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("test 6: quota with --before parameter", async () => {
|
||||||
|
const casDir = join(tmpDir, "cas");
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
const store = createFsStore(casDir);
|
||||||
|
const schemas = await registerUwfSchemas(store);
|
||||||
|
const detailSchemas = await registerDetailSchemas(store);
|
||||||
|
|
||||||
|
const workflowHash = await store.put(schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
worker: {
|
||||||
|
description: "Worker",
|
||||||
|
goal: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do the work.",
|
||||||
|
output: "Summarize the work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Test task",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create 5 steps
|
||||||
|
const steps: CasRef[] = [];
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
const content = generateContent(300, `Step${i}`);
|
||||||
|
const turnHash = await store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const detailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: `session-${i}`,
|
||||||
|
model: "test-model",
|
||||||
|
duration: 1000,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: steps[i - 2] ?? null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
steps.push(stepHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadId = "01HX2Q3R4S5T6V7W8X9YZ6" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: steps[4] as CasRef });
|
||||||
|
|
||||||
|
// Use --before to limit to steps 1-2, then set quota that allows only 1
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, 500, steps[2] as CasRef, false);
|
||||||
|
|
||||||
|
// Should not contain Step3 or later
|
||||||
|
expect(markdown).not.toMatch(/Step3/);
|
||||||
|
expect(markdown).not.toMatch(/Step4/);
|
||||||
|
expect(markdown).not.toMatch(/Step5/);
|
||||||
|
|
||||||
|
// Quota should select most recent of candidates (Step2)
|
||||||
|
expect(markdown).toMatch(/Step2/);
|
||||||
|
|
||||||
|
// Quota enforcement (allow ~200 char tolerance)
|
||||||
|
expect(markdown.length).toBeLessThanOrEqual(700);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,683 @@
|
|||||||
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { bootstrap, putSchema } from "@uncaged/json-cas";
|
||||||
|
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||||
|
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import { cmdThreadRead, THREAD_READ_DEFAULT_QUOTA } from "../commands/thread.js";
|
||||||
|
import { registerUwfSchemas } from "../schemas.js";
|
||||||
|
import type { UwfStore } from "../store.js";
|
||||||
|
import { saveThreadsIndex } from "../store.js";
|
||||||
|
|
||||||
|
// ── schemas used in tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TURN_SCHEMA = {
|
||||||
|
title: "hermes-turn",
|
||||||
|
type: "object" as const,
|
||||||
|
required: ["index", "role", "content"],
|
||||||
|
properties: {
|
||||||
|
index: { type: "integer" as const },
|
||||||
|
role: { type: "string" as const },
|
||||||
|
content: { type: "string" as const },
|
||||||
|
toolCalls: {
|
||||||
|
anyOf: [
|
||||||
|
{ type: "array" as const, items: { type: "object" as const } },
|
||||||
|
{ type: "null" as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
reasoning: { anyOf: [{ type: "string" as const }, { type: "null" as const }] },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DETAIL_SCHEMA = {
|
||||||
|
title: "hermes-detail",
|
||||||
|
type: "object" as const,
|
||||||
|
required: ["sessionId", "model", "duration", "turnCount", "turns"],
|
||||||
|
properties: {
|
||||||
|
sessionId: { type: "string" as const },
|
||||||
|
model: { type: "string" as const },
|
||||||
|
duration: { type: "integer" as const },
|
||||||
|
turnCount: { type: "integer" as const },
|
||||||
|
turns: {
|
||||||
|
type: "array" as const,
|
||||||
|
items: { type: "string" as const, format: "cas_ref" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
|
||||||
|
const casDir = join(storageRoot, "cas");
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
const store = createFsStore(casDir);
|
||||||
|
const schemas = await registerUwfSchemas(store);
|
||||||
|
return { storageRoot, store, schemas };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerDetailSchemas(store: ReturnType<typeof createFsStore>) {
|
||||||
|
await bootstrap(store);
|
||||||
|
const [turn, detail] = await Promise.all([
|
||||||
|
putSchema(store, TURN_SCHEMA),
|
||||||
|
putSchema(store, DETAIL_SCHEMA),
|
||||||
|
]);
|
||||||
|
return { turn, detail };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fixture ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-test-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── thread read XML tag isolation ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("thread read XML tag isolation", () => {
|
||||||
|
test("scenario 1: wraps output in XML tags instead of heading", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const detailSchemas = await registerDetailSchemas(uwf.store);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
planner: {
|
||||||
|
description: "Planner",
|
||||||
|
goal: "You are a planning agent. Your task is to...",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Plan the work.",
|
||||||
|
output: "Summarize the plan.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Fix issue #459",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const turnHash = await uwf.store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content:
|
||||||
|
"---\nstatus: ready\nplan: CMWGHQKT58RY4\n---\n\n# Analysis Complete\n## Issue Summary\nThe issue requires XML tag isolation.",
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const detailHash = await uwf.store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "sx",
|
||||||
|
model: "mx",
|
||||||
|
duration: 500,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "planner",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-claude-code",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000001" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||||
|
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||||
|
|
||||||
|
// Should wrap output in XML tags
|
||||||
|
expect(markdown).toContain("<output>");
|
||||||
|
expect(markdown).toContain("</output>");
|
||||||
|
|
||||||
|
// Should not have ### Content heading
|
||||||
|
expect(markdown).not.toContain("### Content");
|
||||||
|
|
||||||
|
// Should preserve markdown headings inside output tags
|
||||||
|
expect(markdown).toContain("# Analysis Complete");
|
||||||
|
expect(markdown).toContain("## Issue Summary");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scenario 2: wraps prompt in XML tags", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const detailSchemas = await registerDetailSchemas(uwf.store);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
planner: {
|
||||||
|
description: "Planner",
|
||||||
|
goal: "You are a planning agent. Your task is to analyze and plan.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Plan the work.",
|
||||||
|
output: "Summarize the plan.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Fix issue",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const turnHash = await uwf.store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content: "---\nstatus: ready\n---\n\nContent here...",
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const detailHash = await uwf.store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "sx",
|
||||||
|
model: "mx",
|
||||||
|
duration: 500,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "planner",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-claude-code",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000002" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||||
|
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||||
|
|
||||||
|
// Should wrap prompt in XML tags
|
||||||
|
expect(markdown).toContain("<prompt>");
|
||||||
|
expect(markdown).toContain("</prompt>");
|
||||||
|
expect(markdown).toContain("You are a planning agent. Your task is to analyze and plan.");
|
||||||
|
|
||||||
|
// Should not have ### Prompt heading
|
||||||
|
expect(markdown).not.toContain("### Prompt");
|
||||||
|
|
||||||
|
// Should wrap output in XML tags
|
||||||
|
expect(markdown).toContain("<output>");
|
||||||
|
expect(markdown).toContain("</output>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scenario 3: same role repeated does not show prompt twice", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
writer: {
|
||||||
|
description: "Writer",
|
||||||
|
goal: "You are a writer agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Write content.",
|
||||||
|
output: "Summarize writing.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Write something",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const step1 = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "writer",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: step1 as CasRef,
|
||||||
|
role: "writer",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000003" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: step2 });
|
||||||
|
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||||
|
|
||||||
|
// Should only show prompt tags once
|
||||||
|
const promptCount = (markdown.match(/<prompt>/g) ?? []).length;
|
||||||
|
expect(promptCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scenario 4: step with no detail shows no output tags", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
worker: {
|
||||||
|
description: "Worker",
|
||||||
|
goal: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do work.",
|
||||||
|
output: "Summarize work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Do stuff",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000004" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||||
|
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||||
|
|
||||||
|
// Should not have output tags
|
||||||
|
expect(markdown).not.toContain("<output>");
|
||||||
|
expect(markdown).not.toContain("</output>");
|
||||||
|
|
||||||
|
// Step header should still be displayed
|
||||||
|
expect(markdown).toContain("## Step 1: worker");
|
||||||
|
|
||||||
|
// Prompt should still be shown
|
||||||
|
expect(markdown).toContain("<prompt>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scenario 5: empty content shows no output tags", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Do stuff",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// A detail ref that doesn't exist → extractLastAssistantContent returns null
|
||||||
|
const missingDetailRef = "missingdetail0" as CasRef;
|
||||||
|
|
||||||
|
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: missingDetailRef,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000005" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||||
|
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||||
|
|
||||||
|
// Should not have output tags
|
||||||
|
expect(markdown).not.toContain("<output>");
|
||||||
|
expect(markdown).not.toContain("</output>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scenario 6: thread read with --start flag shows task section", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
roleA: {
|
||||||
|
description: "Role A",
|
||||||
|
goal: "Goal for roleA",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do stuff.",
|
||||||
|
output: "Output.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Initial prompt",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "roleA",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000006" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||||
|
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, true);
|
||||||
|
|
||||||
|
// Should include task section
|
||||||
|
expect(markdown).toContain("# Thread");
|
||||||
|
expect(markdown).toContain("## Task");
|
||||||
|
expect(markdown).toContain("Initial prompt");
|
||||||
|
|
||||||
|
// Prompts should use XML tags
|
||||||
|
expect(markdown).toContain("<prompt>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scenario 7: thread read with --before parameter", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
roleA: {
|
||||||
|
description: "Role A",
|
||||||
|
goal: "Goal for roleA",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do stuff.",
|
||||||
|
output: "Output.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
roleB: {
|
||||||
|
description: "Role B",
|
||||||
|
goal: "Goal for roleB",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do stuff.",
|
||||||
|
output: "Output.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
roleC: {
|
||||||
|
description: "Role C",
|
||||||
|
goal: "Goal for roleC",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do stuff.",
|
||||||
|
output: "Output.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Initial prompt",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const step1 = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "roleA",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: step1 as CasRef,
|
||||||
|
role: "roleB",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const step3 = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: step2 as CasRef,
|
||||||
|
role: "roleC",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000007" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: step3 });
|
||||||
|
|
||||||
|
const markdown = await cmdThreadRead(
|
||||||
|
tmpDir,
|
||||||
|
threadId,
|
||||||
|
THREAD_READ_DEFAULT_QUOTA,
|
||||||
|
step2 as CasRef,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should only show roleA
|
||||||
|
expect(markdown).toContain("roleA");
|
||||||
|
expect(markdown).not.toContain("roleB");
|
||||||
|
expect(markdown).not.toContain("roleC");
|
||||||
|
|
||||||
|
// Should use XML tags
|
||||||
|
expect(markdown).toContain("<prompt>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scenario 9: special characters in content are preserved", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const detailSchemas = await registerDetailSchemas(uwf.store);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
writer: {
|
||||||
|
description: "Writer",
|
||||||
|
goal: "You are a writer.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Write content.",
|
||||||
|
output: "Summarize.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Write something",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const turnHash = await uwf.store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content: "Content with <special> & characters > like <this>",
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const detailHash = await uwf.store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "sx",
|
||||||
|
model: "mx",
|
||||||
|
duration: 500,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "writer",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000008" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||||
|
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||||
|
|
||||||
|
// Special characters should be preserved as-is
|
||||||
|
expect(markdown).toContain("Content with <special> & characters > like <this>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scenario 10: quota limit with XML tags", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
roleA: {
|
||||||
|
description: "Role A",
|
||||||
|
goal: "Goal for roleA",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do stuff.",
|
||||||
|
output: "Output.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Initial prompt",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const steps: CasRef[] = [];
|
||||||
|
let prev: CasRef | null = null;
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const step = (await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev,
|
||||||
|
role: "roleA",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
})) as CasRef;
|
||||||
|
steps.push(step);
|
||||||
|
prev = step;
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000009" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: steps[steps.length - 1]! });
|
||||||
|
|
||||||
|
// Use very small quota
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, 1, null, false);
|
||||||
|
|
||||||
|
// Should have skip hint
|
||||||
|
expect(markdown).toContain("earlier step");
|
||||||
|
|
||||||
|
// Should have XML tags for displayed steps
|
||||||
|
if (markdown.includes("<prompt>")) {
|
||||||
|
expect(markdown).toContain("</prompt>");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,48 +22,48 @@ function runCli(args: string[]): { stdout: string; stderr: string; exitCode: num
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("thread step --count CLI parsing", () => {
|
describe("thread exec --count CLI parsing", () => {
|
||||||
test("--help shows -c/--count option", () => {
|
test("--help shows -c/--count option", () => {
|
||||||
const result = runCli(["thread", "step", "--help"]);
|
const result = runCli(["thread", "exec", "--help"]);
|
||||||
expect(result.stdout).toContain("--count");
|
expect(result.stdout).toContain("--count");
|
||||||
expect(result.stdout).toContain("-c");
|
expect(result.stdout).toContain("-c");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("description says 'one or more steps'", () => {
|
test("description says 'one or more steps'", () => {
|
||||||
const result = runCli(["thread", "step", "--help"]);
|
const result = runCli(["thread", "exec", "--help"]);
|
||||||
expect(result.stdout).toContain("one or more steps");
|
expect(result.stdout).toContain("one or more steps");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("cmdThreadStep count logic", () => {
|
describe("cmdThreadExec count logic", () => {
|
||||||
test("count=0 fails with validation error", () => {
|
test("count=0 fails with validation error", () => {
|
||||||
const result = runCli(["thread", "step", "FAKE_THREAD_ID", "-c", "0"]);
|
const result = runCli(["thread", "exec", "FAKE_THREAD_ID", "-c", "0"]);
|
||||||
expect(result.exitCode).not.toBe(0);
|
expect(result.exitCode).not.toBe(0);
|
||||||
expect(result.stderr).toContain("positive integer");
|
expect(result.stderr).toContain("positive integer");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("negative count fails with validation error", () => {
|
test("negative count fails with validation error", () => {
|
||||||
const result = runCli(["thread", "step", "FAKE_THREAD_ID", "-c", "-1"]);
|
const result = runCli(["thread", "exec", "FAKE_THREAD_ID", "-c", "-1"]);
|
||||||
expect(result.exitCode).not.toBe(0);
|
expect(result.exitCode).not.toBe(0);
|
||||||
expect(result.stderr).toContain("positive integer");
|
expect(result.stderr).toContain("positive integer");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("non-integer count fails with validation error", () => {
|
test("non-integer count fails with validation error", () => {
|
||||||
const result = runCli(["thread", "step", "FAKE_THREAD_ID", "-c", "1.5"]);
|
const result = runCli(["thread", "exec", "FAKE_THREAD_ID", "-c", "1.5"]);
|
||||||
expect(result.exitCode).not.toBe(0);
|
expect(result.exitCode).not.toBe(0);
|
||||||
expect(result.stderr).toContain("positive integer");
|
expect(result.stderr).toContain("positive integer");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("count=1 is the default (no -c flag)", () => {
|
test("count=1 is the default (no -c flag)", () => {
|
||||||
// Without -c, it should attempt to run 1 step (failing on missing thread, not on count validation)
|
// Without -c, it should attempt to run 1 step (failing on missing thread, not on count validation)
|
||||||
const result = runCli(["thread", "step", "FAKE_THREAD_ID"]);
|
const result = runCli(["thread", "exec", "FAKE_THREAD_ID"]);
|
||||||
expect(result.exitCode).not.toBe(0);
|
expect(result.exitCode).not.toBe(0);
|
||||||
// Should NOT contain "positive integer" error — should fail on thread lookup instead
|
// Should NOT contain "positive integer" error — should fail on thread lookup instead
|
||||||
expect(result.stderr).not.toContain("positive integer");
|
expect(result.stderr).not.toContain("positive integer");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("count=3 passes validation (fails on thread lookup)", () => {
|
test("count=3 passes validation (fails on thread lookup)", () => {
|
||||||
const result = runCli(["thread", "step", "FAKE_THREAD_ID", "-c", "3"]);
|
const result = runCli(["thread", "exec", "FAKE_THREAD_ID", "-c", "3"]);
|
||||||
expect(result.exitCode).not.toBe(0);
|
expect(result.exitCode).not.toBe(0);
|
||||||
// Should NOT contain "positive integer" error — should fail on thread/storage lookup
|
// Should NOT contain "positive integer" error — should fail on thread/storage lookup
|
||||||
expect(result.stderr).not.toContain("positive integer");
|
expect(result.stderr).not.toContain("positive integer");
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ import { bootstrap, putSchema } from "@uncaged/json-cas";
|
|||||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||||
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import { cmdStepList, cmdStepShow } from "../commands/step.js";
|
||||||
import {
|
import {
|
||||||
cmdThreadRead,
|
cmdThreadRead,
|
||||||
cmdThreadStepDetails,
|
|
||||||
extractLastAssistantContent,
|
extractLastAssistantContent,
|
||||||
THREAD_READ_DEFAULT_QUOTA,
|
THREAD_READ_DEFAULT_QUOTA,
|
||||||
} from "../commands/thread.js";
|
} from "../commands/thread.js";
|
||||||
import { registerUwfSchemas } from "../schemas.js";
|
import { registerUwfSchemas } from "../schemas.js";
|
||||||
import type { UwfStore } from "../store.js";
|
import type { UwfStore } from "../store.js";
|
||||||
import { saveThreadsIndex } from "../store.js";
|
import { appendThreadHistory, saveThreadsIndex } from "../store.js";
|
||||||
|
|
||||||
// ── schemas used in tests ────────────────────────────────────────────────────
|
// ── schemas used in tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -198,10 +198,10 @@ describe("extractLastAssistantContent", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── cmdThreadRead: ### Content section ───────────────────────────────────────
|
// ── cmdThreadRead: <output> section ──────────────────────────────────────────
|
||||||
|
|
||||||
describe("cmdThreadRead ### Content section", () => {
|
describe("cmdThreadRead <output> section", () => {
|
||||||
test("includes ### Content before ### Output when detail has assistant turns", async () => {
|
test("includes <output> tags when detail has assistant turns", async () => {
|
||||||
const uwf = await makeUwfStore(tmpDir);
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
const detailSchemas = await registerDetailSchemas(uwf.store);
|
const detailSchemas = await registerDetailSchemas(uwf.store);
|
||||||
|
|
||||||
@@ -264,12 +264,13 @@ describe("cmdThreadRead ### Content section", () => {
|
|||||||
|
|
||||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||||
|
|
||||||
expect(markdown).toContain("### Content");
|
expect(markdown).toContain("<output>");
|
||||||
|
expect(markdown).toContain("</output>");
|
||||||
expect(markdown).toContain("The assistant response text");
|
expect(markdown).toContain("The assistant response text");
|
||||||
expect(markdown).not.toContain("### Output");
|
expect(markdown).not.toContain("### Content");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("omits ### Content when detail has no matching assistant turns", async () => {
|
test("omits <output> tags when detail has no matching assistant turns", async () => {
|
||||||
const uwf = await makeUwfStore(tmpDir);
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
|
||||||
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
@@ -308,14 +309,15 @@ describe("cmdThreadRead ### Content section", () => {
|
|||||||
|
|
||||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||||
|
|
||||||
|
expect(markdown).not.toContain("<output>");
|
||||||
|
expect(markdown).not.toContain("</output>");
|
||||||
expect(markdown).not.toContain("### Content");
|
expect(markdown).not.toContain("### Content");
|
||||||
expect(markdown).not.toContain("### Output");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── cmdThreadStepDetails ──────────────────────────────────────────────────────
|
// ── cmdStepShow ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("cmdThreadStepDetails", () => {
|
describe("cmdStepShow", () => {
|
||||||
test("returns expanded detail node with turns inlined", async () => {
|
test("returns expanded detail node with turns inlined", async () => {
|
||||||
const uwf = await makeUwfStore(tmpDir);
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
const detailSchemas = await registerDetailSchemas(uwf.store);
|
const detailSchemas = await registerDetailSchemas(uwf.store);
|
||||||
@@ -363,7 +365,7 @@ describe("cmdThreadStepDetails", () => {
|
|||||||
agent: "uwf-hermes",
|
agent: "uwf-hermes",
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await cmdThreadStepDetails(tmpDir, stepHash);
|
const result = await cmdStepShow(tmpDir, stepHash);
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
sessionId: "sess42",
|
sessionId: "sess42",
|
||||||
@@ -384,9 +386,9 @@ describe("cmdThreadStepDetails", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── cmdThreadRead: ### Prompt deduplication ───────────────────────────────────
|
// ── cmdThreadRead: <prompt> deduplication ────────────────────────────────────
|
||||||
|
|
||||||
describe("cmdThreadRead ### Prompt deduplication", () => {
|
describe("cmdThreadRead <prompt> deduplication", () => {
|
||||||
async function makeThreadWithRoles(uwf: UwfStore, roles: string[]): Promise<string> {
|
async function makeThreadWithRoles(uwf: UwfStore, roles: string[]): Promise<string> {
|
||||||
const roleMap: Record<string, unknown> = {};
|
const roleMap: Record<string, unknown> = {};
|
||||||
for (const r of [...new Set(roles)]) {
|
for (const r of [...new Set(roles)]) {
|
||||||
@@ -434,36 +436,36 @@ describe("cmdThreadRead ### Prompt deduplication", () => {
|
|||||||
return stepHash;
|
return stepHash;
|
||||||
}
|
}
|
||||||
|
|
||||||
test("same consecutive role shows ### Prompt once", async () => {
|
test("same consecutive role shows <prompt> once", async () => {
|
||||||
const uwf = await makeUwfStore(tmpDir);
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
const headHash = await makeThreadWithRoles(uwf, ["writer", "writer"]);
|
const headHash = await makeThreadWithRoles(uwf, ["writer", "writer"]);
|
||||||
const threadId = "01JTEST0000000000000003" as ThreadId;
|
const threadId = "01JTEST0000000000000003" as ThreadId;
|
||||||
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
|
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
|
||||||
|
|
||||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||||
const count = (markdown.match(/### Prompt/g) ?? []).length;
|
const count = (markdown.match(/<prompt>/g) ?? []).length;
|
||||||
expect(count).toBe(1);
|
expect(count).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("different consecutive roles each show ### Prompt", async () => {
|
test("different consecutive roles each show <prompt>", async () => {
|
||||||
const uwf = await makeUwfStore(tmpDir);
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
const headHash = await makeThreadWithRoles(uwf, ["planner", "coder"]);
|
const headHash = await makeThreadWithRoles(uwf, ["planner", "coder"]);
|
||||||
const threadId = "01JTEST0000000000000004" as ThreadId;
|
const threadId = "01JTEST0000000000000004" as ThreadId;
|
||||||
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
|
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
|
||||||
|
|
||||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||||
const count = (markdown.match(/### Prompt/g) ?? []).length;
|
const count = (markdown.match(/<prompt>/g) ?? []).length;
|
||||||
expect(count).toBe(2);
|
expect(count).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("non-consecutive same role shows ### Prompt twice", async () => {
|
test("non-consecutive same role shows <prompt> twice", async () => {
|
||||||
const uwf = await makeUwfStore(tmpDir);
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
const headHash = await makeThreadWithRoles(uwf, ["roleA", "roleB", "roleA"]);
|
const headHash = await makeThreadWithRoles(uwf, ["roleA", "roleB", "roleA"]);
|
||||||
const threadId = "01JTEST0000000000000005" as ThreadId;
|
const threadId = "01JTEST0000000000000005" as ThreadId;
|
||||||
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
|
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
|
||||||
|
|
||||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||||
const count = (markdown.match(/### Prompt/g) ?? []).length;
|
const count = (markdown.match(/<prompt>/g) ?? []).length;
|
||||||
expect(count).toBe(2);
|
expect(count).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -584,9 +586,9 @@ describe("cmdThreadRead start section / before / quota", () => {
|
|||||||
|
|
||||||
// ── Tests that call process.exit must be last ─────────────────────────────────
|
// ── Tests that call process.exit must be last ─────────────────────────────────
|
||||||
|
|
||||||
describe("cmdThreadStepDetails (process.exit tests - must be last)", () => {
|
describe("cmdStepShow (process.exit tests - must be last)", () => {
|
||||||
test("throws when step hash does not exist", async () => {
|
test("throws when step hash does not exist", async () => {
|
||||||
await expect(cmdThreadStepDetails(tmpDir, "nonexistenth0" as CasRef)).rejects.toThrow();
|
await expect(cmdStepShow(tmpDir, "nonexistenth0" as CasRef)).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("before with unknown hash rejects", async () => {
|
test("before with unknown hash rejects", async () => {
|
||||||
@@ -645,3 +647,383 @@ describe("cmdThreadStepDetails (process.exit tests - must be last)", () => {
|
|||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── cmdStepList / cmdStepShow: completed threads ──────────────────────────────
|
||||||
|
|
||||||
|
describe("cmdStepList with completed threads", () => {
|
||||||
|
test("lists steps from active thread", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf-active",
|
||||||
|
description: "desc",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Start prompt",
|
||||||
|
});
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const step1Hash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "role1",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
const step2Hash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: step1Hash,
|
||||||
|
role: "role2",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
const step3Hash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: step2Hash,
|
||||||
|
role: "role3",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000000A1" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: step3Hash });
|
||||||
|
|
||||||
|
const result = await cmdStepList(tmpDir, threadId);
|
||||||
|
|
||||||
|
expect(result.thread).toBe(threadId);
|
||||||
|
expect(result.steps).toHaveLength(4); // start + 3 steps
|
||||||
|
expect(result.steps[1].role).toBe("role1");
|
||||||
|
expect(result.steps[2].role).toBe("role2");
|
||||||
|
expect(result.steps[3].role).toBe("role3");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists steps from completed thread", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf-completed",
|
||||||
|
description: "desc",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Start prompt",
|
||||||
|
});
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const step1Hash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "roleA",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
const step2Hash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: step1Hash,
|
||||||
|
role: "roleB",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000000A2" as ThreadId;
|
||||||
|
// Thread is NOT in threads.yaml (simulating completed thread)
|
||||||
|
await saveThreadsIndex(tmpDir, {});
|
||||||
|
// But it IS in history.jsonl
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: step2Hash,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await cmdStepList(tmpDir, threadId);
|
||||||
|
|
||||||
|
expect(result.thread).toBe(threadId);
|
||||||
|
expect(result.steps).toHaveLength(3); // start + 2 steps
|
||||||
|
expect(result.steps[1].role).toBe("roleA");
|
||||||
|
expect(result.steps[2].role).toBe("roleB");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cmdStepShow with completed threads", () => {
|
||||||
|
test("shows step detail from active thread", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const detailSchemas = await registerDetailSchemas(uwf.store);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf-step-active",
|
||||||
|
description: "desc",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "p",
|
||||||
|
});
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const turnHash = await uwf.store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content: "Active thread response",
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const detailHash = await uwf.store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "sess-active",
|
||||||
|
model: "model-x",
|
||||||
|
duration: 1234,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "coder",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000000B1" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||||
|
|
||||||
|
const result = await cmdStepShow(tmpDir, stepHash);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
sessionId: "sess-active",
|
||||||
|
model: "model-x",
|
||||||
|
duration: 1234,
|
||||||
|
turnCount: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows step detail from completed thread", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const detailSchemas = await registerDetailSchemas(uwf.store);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf-step-completed",
|
||||||
|
description: "desc",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "p",
|
||||||
|
});
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const turnHash = await uwf.store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content: "Completed thread response",
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const detailHash = await uwf.store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "sess-completed",
|
||||||
|
model: "model-y",
|
||||||
|
duration: 5678,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "reviewer",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000000B2" as ThreadId;
|
||||||
|
// Thread is NOT in threads.yaml
|
||||||
|
await saveThreadsIndex(tmpDir, {});
|
||||||
|
// But it IS in history.jsonl
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: stepHash,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await cmdStepShow(tmpDir, stepHash);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
sessionId: "sess-completed",
|
||||||
|
model: "model-y",
|
||||||
|
duration: 5678,
|
||||||
|
turnCount: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cmdThreadRead with completed threads", () => {
|
||||||
|
test("reads completed thread context", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf-read-completed",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
writer: {
|
||||||
|
description: "Write",
|
||||||
|
goal: "You are a writer.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Write content.",
|
||||||
|
output: "Summary.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Write something",
|
||||||
|
});
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "writer",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000000C1" as ThreadId;
|
||||||
|
// Thread is NOT in threads.yaml
|
||||||
|
await saveThreadsIndex(tmpDir, {});
|
||||||
|
// But it IS in history.jsonl
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: stepHash,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||||
|
|
||||||
|
expect(markdown).toContain("writer");
|
||||||
|
expect(markdown).toContain("Write something");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reads completed thread with before filter", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "test-wf-read-before",
|
||||||
|
description: "desc",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Do task",
|
||||||
|
});
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const step1Hash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "roleX",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
const step2Hash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: step1Hash,
|
||||||
|
role: "roleY",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
const step3Hash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: step2Hash,
|
||||||
|
role: "roleZ",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01JTEST0000000000000000C2" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, {});
|
||||||
|
await appendThreadHistory(tmpDir, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow: workflowHash,
|
||||||
|
head: step3Hash,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const markdown = await cmdThreadRead(
|
||||||
|
tmpDir,
|
||||||
|
threadId,
|
||||||
|
THREAD_READ_DEFAULT_QUOTA,
|
||||||
|
step2Hash,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should contain step1 (roleX) but not step2 (roleY) or step3 (roleZ)
|
||||||
|
expect(markdown).toContain("roleX");
|
||||||
|
expect(markdown).not.toContain("roleY");
|
||||||
|
expect(markdown).not.toContain("roleZ");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { RunningThreadItem, ThreadId } from "@uncaged/workflow-protocol";
|
||||||
|
|
||||||
|
import type { RunningMarker } from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the path to the running markers directory.
|
||||||
|
*/
|
||||||
|
export function getRunningDir(storageRoot: string): string {
|
||||||
|
return join(storageRoot, "running");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the path to a specific thread's marker file.
|
||||||
|
*/
|
||||||
|
export function getMarkerPath(storageRoot: string, threadId: ThreadId): string {
|
||||||
|
return join(getRunningDir(storageRoot), `${threadId}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a PID is still running.
|
||||||
|
* Returns true if the process exists, false otherwise.
|
||||||
|
*/
|
||||||
|
export function isPidAlive(pid: number): boolean {
|
||||||
|
try {
|
||||||
|
// process.kill with signal 0 checks existence without killing
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// ESRCH means process doesn't exist
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a marker file for a running thread.
|
||||||
|
* Writes to a temp file in the same directory, then atomically renames.
|
||||||
|
*/
|
||||||
|
export async function createMarker(storageRoot: string, marker: RunningMarker): Promise<void> {
|
||||||
|
const runningDir = getRunningDir(storageRoot);
|
||||||
|
await mkdir(runningDir, { recursive: true });
|
||||||
|
|
||||||
|
const markerPath = getMarkerPath(storageRoot, marker.thread);
|
||||||
|
const tempPath = join(runningDir, `.${marker.thread}-${process.pid}.tmp`);
|
||||||
|
|
||||||
|
const content = JSON.stringify(marker, null, 2);
|
||||||
|
await writeFile(tempPath, content, "utf8");
|
||||||
|
await rename(tempPath, markerPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a marker file for a thread.
|
||||||
|
*/
|
||||||
|
export async function deleteMarker(storageRoot: string, threadId: ThreadId): Promise<void> {
|
||||||
|
const markerPath = getMarkerPath(storageRoot, threadId);
|
||||||
|
try {
|
||||||
|
await rm(markerPath);
|
||||||
|
} catch {
|
||||||
|
// Ignore errors if file doesn't exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a marker file. Returns null if file doesn't exist or is invalid.
|
||||||
|
*/
|
||||||
|
export async function readMarker(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: ThreadId,
|
||||||
|
): Promise<RunningMarker | null> {
|
||||||
|
const markerPath = getMarkerPath(storageRoot, threadId);
|
||||||
|
try {
|
||||||
|
const content = await readFile(markerPath, "utf8");
|
||||||
|
const marker = JSON.parse(content) as RunningMarker;
|
||||||
|
return marker;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all running threads, filtering out stale markers.
|
||||||
|
*/
|
||||||
|
export async function listRunningThreads(storageRoot: string): Promise<RunningThreadItem[]> {
|
||||||
|
const runningDir = getRunningDir(storageRoot);
|
||||||
|
|
||||||
|
let files: string[];
|
||||||
|
try {
|
||||||
|
files = await readdir(runningDir);
|
||||||
|
} catch {
|
||||||
|
// Directory doesn't exist or can't be read
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: RunningThreadItem[] = [];
|
||||||
|
|
||||||
|
for (const filename of files) {
|
||||||
|
if (!filename.endsWith(".json")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadId = filename.slice(0, -5) as ThreadId;
|
||||||
|
const marker = await readMarker(storageRoot, threadId);
|
||||||
|
|
||||||
|
if (marker === null) {
|
||||||
|
// Invalid marker file
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPidAlive(marker.pid)) {
|
||||||
|
// Stale marker - process no longer exists
|
||||||
|
await deleteMarker(storageRoot, threadId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
thread: marker.thread,
|
||||||
|
workflow: marker.workflow,
|
||||||
|
pid: marker.pid,
|
||||||
|
startedAt: marker.startedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a thread is currently executing in the background.
|
||||||
|
* Returns the marker if running, null otherwise.
|
||||||
|
*/
|
||||||
|
export async function isThreadRunning(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: ThreadId,
|
||||||
|
): Promise<RunningMarker | null> {
|
||||||
|
const marker = await readMarker(storageRoot, threadId);
|
||||||
|
if (marker === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPidAlive(marker.pid)) {
|
||||||
|
// Stale marker
|
||||||
|
await deleteMarker(storageRoot, threadId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return marker;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export {
|
||||||
|
createMarker,
|
||||||
|
deleteMarker,
|
||||||
|
getMarkerPath,
|
||||||
|
getRunningDir,
|
||||||
|
isPidAlive,
|
||||||
|
isThreadRunning,
|
||||||
|
listRunningThreads,
|
||||||
|
readMarker,
|
||||||
|
} from "./background.js";
|
||||||
|
export type { RunningMarker } from "./types.js";
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||||
|
|
||||||
|
/** Marker file stored at ~/.uncaged/workflow/running/<thread-id>.json */
|
||||||
|
export type RunningMarker = {
|
||||||
|
thread: ThreadId;
|
||||||
|
workflow: CasRef;
|
||||||
|
pid: number;
|
||||||
|
startedAt: number;
|
||||||
|
};
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
import type { ThreadId } from "@uncaged/workflow-protocol";
|
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { stringify as yamlStringify } from "yaml";
|
|
||||||
import {
|
import {
|
||||||
cmdCasGet,
|
cmdCasGet,
|
||||||
cmdCasHas,
|
cmdCasHas,
|
||||||
@@ -17,19 +16,20 @@ import {
|
|||||||
import { cmdLogClean, cmdLogList, cmdLogShow } from "./commands/log.js";
|
import { cmdLogClean, cmdLogList, cmdLogShow } from "./commands/log.js";
|
||||||
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
|
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
|
||||||
import { cmdSkillCli } from "./commands/skill.js";
|
import { cmdSkillCli } from "./commands/skill.js";
|
||||||
|
import { cmdStepFork, cmdStepList, cmdStepRead, cmdStepShow } from "./commands/step.js";
|
||||||
import {
|
import {
|
||||||
cmdThreadFork,
|
cmdThreadCancel,
|
||||||
cmdThreadKill,
|
cmdThreadExec,
|
||||||
cmdThreadList,
|
cmdThreadList,
|
||||||
cmdThreadRead,
|
cmdThreadRead,
|
||||||
cmdThreadShow,
|
cmdThreadShow,
|
||||||
cmdThreadStart,
|
cmdThreadStart,
|
||||||
cmdThreadStep,
|
cmdThreadStop,
|
||||||
cmdThreadStepDetails,
|
|
||||||
cmdThreadSteps,
|
|
||||||
THREAD_READ_DEFAULT_QUOTA,
|
THREAD_READ_DEFAULT_QUOTA,
|
||||||
|
type ThreadStatus,
|
||||||
} from "./commands/thread.js";
|
} from "./commands/thread.js";
|
||||||
import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js";
|
import { parseTimeInput } from "./commands/thread-time-parser.js";
|
||||||
|
import { cmdWorkflowAdd, cmdWorkflowList, cmdWorkflowShow } from "./commands/workflow.js";
|
||||||
import { formatOutput, type OutputFormat } from "./format.js";
|
import { formatOutput, type OutputFormat } from "./format.js";
|
||||||
import { resolveStorageRoot } from "./store.js";
|
import { resolveStorageRoot } from "./store.js";
|
||||||
|
|
||||||
@@ -52,20 +52,27 @@ const program = new Command();
|
|||||||
const pkg = await import("../package.json", { with: { type: "json" } });
|
const pkg = await import("../package.json", { with: { type: "json" } });
|
||||||
program
|
program
|
||||||
.name("uwf")
|
.name("uwf")
|
||||||
.description("Stateless workflow CLI")
|
.description(
|
||||||
|
"Stateless workflow CLI\n\n" +
|
||||||
|
"Four-layer architecture:\n" +
|
||||||
|
" workflow → thread → step → turn\n" +
|
||||||
|
" 模板定义 执行实例 单步结果 agent内部交互",
|
||||||
|
)
|
||||||
.version(pkg.default.version, "-V, --version");
|
.version(pkg.default.version, "-V, --version");
|
||||||
program.option("--format <fmt>", "Output format: json or yaml", "json");
|
program.option("--format <fmt>", "Output format: json or yaml", "json");
|
||||||
|
|
||||||
const workflow = program.command("workflow").description("Workflow registry and CAS");
|
const workflow = program
|
||||||
|
.command("workflow")
|
||||||
|
.description("Workflow definitions (layer 1: templates)");
|
||||||
|
|
||||||
workflow
|
workflow
|
||||||
.command("put")
|
.command("add")
|
||||||
.description("Register a workflow from YAML")
|
.description("Register a workflow from YAML")
|
||||||
.argument("<file>", "Workflow YAML file")
|
.argument("<file>", "Workflow YAML file")
|
||||||
.action((file: string) => {
|
.action((file: string) => {
|
||||||
const storageRoot = resolveStorageRoot();
|
const storageRoot = resolveStorageRoot();
|
||||||
runAction(async () => {
|
runAction(async () => {
|
||||||
const result = await cmdWorkflowPut(storageRoot, file);
|
const result = await cmdWorkflowAdd(storageRoot, file);
|
||||||
writeOutput(result);
|
writeOutput(result);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -93,7 +100,7 @@ workflow
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const thread = program.command("thread").description("Thread lifecycle and execution");
|
const thread = program.command("thread").description("Thread execution (layer 2: instances)");
|
||||||
|
|
||||||
thread
|
thread
|
||||||
.command("start")
|
.command("start")
|
||||||
@@ -109,24 +116,46 @@ thread
|
|||||||
});
|
});
|
||||||
|
|
||||||
thread
|
thread
|
||||||
.command("step")
|
.command("exec")
|
||||||
.description("Execute one or more steps")
|
.description("Execute one or more steps")
|
||||||
.argument("<thread-id>", "Thread ULID")
|
.argument("<thread-id>", "Thread ULID")
|
||||||
.option("--agent <cmd>", "Override agent command")
|
.option("--agent <cmd>", "Override agent command")
|
||||||
.option("-c, --count <number>", "Number of steps to run (default: 1)")
|
.option("-c, --count <number>", "Number of steps to run (default: 1)")
|
||||||
.action((threadId: string, opts: { agent: string | undefined; count: string | undefined }) => {
|
.option("--background", "Run in background and return immediately")
|
||||||
const storageRoot = resolveStorageRoot();
|
.option("--_background-worker", "Internal flag for background worker process", false)
|
||||||
runAction(async () => {
|
.action(
|
||||||
const agentOverride = opts.agent ?? null;
|
(
|
||||||
const count = opts.count !== undefined ? Number(opts.count) : 1;
|
threadId: string,
|
||||||
const results = await cmdThreadStep(storageRoot, threadId, agentOverride, count);
|
opts: {
|
||||||
if (results.length === 1) {
|
agent: string | undefined;
|
||||||
writeOutput(results[0]);
|
count: string | undefined;
|
||||||
} else {
|
background: boolean;
|
||||||
writeOutput(results);
|
_backgroundWorker: boolean;
|
||||||
}
|
},
|
||||||
});
|
) => {
|
||||||
});
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const agentOverride = opts.agent ?? null;
|
||||||
|
const count = opts.count !== undefined ? Number(opts.count) : 1;
|
||||||
|
const background = opts.background ?? false;
|
||||||
|
const backgroundWorker = opts._backgroundWorker ?? false;
|
||||||
|
|
||||||
|
const results = await cmdThreadExec(
|
||||||
|
storageRoot,
|
||||||
|
threadId,
|
||||||
|
agentOverride,
|
||||||
|
count,
|
||||||
|
background,
|
||||||
|
backgroundWorker,
|
||||||
|
);
|
||||||
|
if (results.length === 1) {
|
||||||
|
writeOutput(results[0]);
|
||||||
|
} else {
|
||||||
|
writeOutput(results);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
thread
|
thread
|
||||||
.command("show")
|
.command("show")
|
||||||
@@ -140,38 +169,124 @@ thread
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper functions for thread list command parsing
|
||||||
|
function parseStatusFilter(status: string | undefined): ThreadStatus[] | null {
|
||||||
|
if (status === undefined) return null;
|
||||||
|
const raw = status.trim();
|
||||||
|
if (raw === "active") return ["idle", "running"];
|
||||||
|
|
||||||
|
const parts = raw.split(",").map((s) => s.trim());
|
||||||
|
const validStatuses: ThreadStatus[] = ["idle", "running", "completed"];
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!validStatuses.includes(part as ThreadStatus)) {
|
||||||
|
process.stderr.write(
|
||||||
|
`Invalid status: ${part}. Must be one of: idle, running, completed, active\n`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts as ThreadStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeFilters(
|
||||||
|
after: string | undefined,
|
||||||
|
before: string | undefined,
|
||||||
|
nowMs: number,
|
||||||
|
): { afterMs: number | null; beforeMs: number | null } {
|
||||||
|
try {
|
||||||
|
const afterMs = after !== undefined ? parseTimeInput(after, nowMs) : null;
|
||||||
|
const beforeMs = before !== undefined ? parseTimeInput(before, nowMs) : null;
|
||||||
|
return { afterMs, beforeMs };
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
process.stderr.write(`${message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePaginationOptions(
|
||||||
|
skip: string | undefined,
|
||||||
|
take: string | undefined,
|
||||||
|
): { skip: number | null; take: number | null } {
|
||||||
|
let skipVal: number | null = null;
|
||||||
|
let takeVal: number | null = null;
|
||||||
|
|
||||||
|
if (skip !== undefined) {
|
||||||
|
skipVal = Number.parseInt(skip, 10);
|
||||||
|
if (!Number.isInteger(skipVal) || skipVal < 0) {
|
||||||
|
process.stderr.write("--skip must be a non-negative integer\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (take !== undefined) {
|
||||||
|
takeVal = Number.parseInt(take, 10);
|
||||||
|
if (!Number.isInteger(takeVal) || takeVal < 1) {
|
||||||
|
process.stderr.write("--take must be a positive integer\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { skip: skipVal, take: takeVal };
|
||||||
|
}
|
||||||
|
|
||||||
thread
|
thread
|
||||||
.command("list")
|
.command("list")
|
||||||
.description("List active threads")
|
.description("List threads")
|
||||||
.option("--all", "Include archived threads")
|
.option(
|
||||||
.action((opts: { all: boolean }) => {
|
"--status <status>",
|
||||||
|
"Filter by status: idle, running, completed, active (idle+running), or comma-separated values",
|
||||||
|
)
|
||||||
|
.option("--after <date>", "Filter threads created after this date (ISO or relative like '7d')")
|
||||||
|
.option("--before <date>", "Filter threads created before this date (ISO or relative like '7d')")
|
||||||
|
.option("--skip <n>", "Skip first n threads")
|
||||||
|
.option("--take <n>", "Return at most n threads")
|
||||||
|
.action(
|
||||||
|
(opts: {
|
||||||
|
status: string | undefined;
|
||||||
|
after: string | undefined;
|
||||||
|
before: string | undefined;
|
||||||
|
skip: string | undefined;
|
||||||
|
take: string | undefined;
|
||||||
|
}) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const statusFilter = parseStatusFilter(opts.status);
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const { afterMs, beforeMs } = parseTimeFilters(opts.after, opts.before, nowMs);
|
||||||
|
const { skip, take } = parsePaginationOptions(opts.skip, opts.take);
|
||||||
|
|
||||||
|
const result = await cmdThreadList(
|
||||||
|
storageRoot,
|
||||||
|
statusFilter,
|
||||||
|
afterMs,
|
||||||
|
beforeMs,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
);
|
||||||
|
writeOutput(result);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("stop")
|
||||||
|
.description("Stop background execution of a thread (keep thread active)")
|
||||||
|
.argument("<thread-id>", "Thread ULID")
|
||||||
|
.action((threadId: string) => {
|
||||||
const storageRoot = resolveStorageRoot();
|
const storageRoot = resolveStorageRoot();
|
||||||
runAction(async () => {
|
runAction(async () => {
|
||||||
const result = await cmdThreadList(storageRoot, opts.all);
|
const result = await cmdThreadStop(storageRoot, threadId);
|
||||||
writeOutput(result);
|
writeOutput(result);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
thread
|
thread
|
||||||
.command("kill")
|
.command("cancel")
|
||||||
.description("Terminate and archive a thread")
|
.description("Cancel a thread (stop execution and move to history)")
|
||||||
.argument("<thread-id>", "Thread ULID")
|
.argument("<thread-id>", "Thread ULID")
|
||||||
.action((threadId: string) => {
|
.action((threadId: string) => {
|
||||||
const storageRoot = resolveStorageRoot();
|
const storageRoot = resolveStorageRoot();
|
||||||
runAction(async () => {
|
runAction(async () => {
|
||||||
const result = await cmdThreadKill(storageRoot, threadId);
|
const result = await cmdThreadCancel(storageRoot, threadId);
|
||||||
writeOutput(result);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
thread
|
|
||||||
.command("steps")
|
|
||||||
.description("List all steps in a thread")
|
|
||||||
.argument("<thread-id>", "Thread ULID")
|
|
||||||
.action((threadId: string) => {
|
|
||||||
const storageRoot = resolveStorageRoot();
|
|
||||||
runAction(async () => {
|
|
||||||
const result = await cmdThreadSteps(storageRoot, threadId);
|
|
||||||
writeOutput(result);
|
writeOutput(result);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -205,28 +320,157 @@ thread
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
thread
|
const step = program.command("step").description("Step results (layer 3: single cycle)");
|
||||||
|
|
||||||
|
step
|
||||||
|
.command("list")
|
||||||
|
.description("List all steps in a thread")
|
||||||
|
.argument("<thread-id>", "Thread ULID")
|
||||||
|
.action((threadId: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const result = await cmdStepList(storageRoot, threadId);
|
||||||
|
writeOutput(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
step
|
||||||
|
.command("show")
|
||||||
|
.description("Show details of a specific step")
|
||||||
|
.argument("<step-hash>", "CAS hash of the StepNode")
|
||||||
|
.action((stepHash: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const detail = await cmdStepShow(storageRoot, stepHash as CasRef);
|
||||||
|
writeOutput(detail);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
step
|
||||||
|
.command("read")
|
||||||
|
.description("Read a step's turns as human-readable markdown")
|
||||||
|
.argument("<step-hash>", "CAS hash of the StepNode")
|
||||||
|
.option("--quota <chars>", "Max output characters", "4000")
|
||||||
|
.action((stepHash: string, opts: { quota: string }) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const quota = Number.parseInt(opts.quota, 10);
|
||||||
|
if (!Number.isFinite(quota) || quota < 1) {
|
||||||
|
process.stderr.write("invalid --quota: must be a positive integer\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const markdown = await cmdStepRead(storageRoot, stepHash as CasRef, quota);
|
||||||
|
process.stdout.write(markdown.endsWith("\n") ? markdown : `${markdown}\n`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
step
|
||||||
.command("fork")
|
.command("fork")
|
||||||
.description("Fork a thread from a specific step")
|
.description("Fork a thread from a specific step")
|
||||||
.argument("<step-hash>", "CAS hash of the StartNode or StepNode to fork from")
|
.argument("<step-hash>", "CAS hash of the StartNode or StepNode to fork from")
|
||||||
.action((stepHash: string) => {
|
.action((stepHash: string) => {
|
||||||
const storageRoot = resolveStorageRoot();
|
const storageRoot = resolveStorageRoot();
|
||||||
runAction(async () => {
|
runAction(async () => {
|
||||||
const result = await cmdThreadFork(storageRoot, stepHash);
|
const result = await cmdStepFork(storageRoot, stepHash as CasRef);
|
||||||
writeOutput(result);
|
writeOutput(result);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Deprecation Handlers ──────────────────────────────────────────────────────
|
||||||
|
// These commands have been removed. Show helpful error messages.
|
||||||
|
|
||||||
|
workflow
|
||||||
|
.command("put")
|
||||||
|
.description("[DEPRECATED] Use 'workflow add' instead")
|
||||||
|
.argument("<file>", "Workflow YAML file")
|
||||||
|
.action(() => {
|
||||||
|
process.stderr.write(`Error: Command 'workflow put' has been removed.
|
||||||
|
Use 'workflow add' instead.
|
||||||
|
|
||||||
|
For more information, see: uwf help workflow add
|
||||||
|
`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("step")
|
||||||
|
.description("[DEPRECATED] Use 'thread exec' instead")
|
||||||
|
.argument("<thread-id>", "Thread ULID")
|
||||||
|
.allowUnknownOption()
|
||||||
|
.action(() => {
|
||||||
|
process.stderr.write(`Error: Command 'thread step' has been removed.
|
||||||
|
Use 'thread exec' instead.
|
||||||
|
|
||||||
|
For more information, see: uwf help thread exec
|
||||||
|
`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("steps")
|
||||||
|
.description("[DEPRECATED] Use 'step list' instead")
|
||||||
|
.argument("<thread-id>", "Thread ULID")
|
||||||
|
.action(() => {
|
||||||
|
process.stderr.write(`Error: Command 'thread steps' has been removed.
|
||||||
|
Use 'step list' instead.
|
||||||
|
|
||||||
|
For more information, see: uwf help step list
|
||||||
|
`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
thread
|
thread
|
||||||
.command("step-details")
|
.command("step-details")
|
||||||
.description("Dump the full detail node of a step as YAML")
|
.description("[DEPRECATED] Use 'step show' instead")
|
||||||
.argument("<step-hash>", "CAS hash of the StepNode")
|
.argument("<step-hash>", "Step hash")
|
||||||
.action((stepHash: string) => {
|
.action(() => {
|
||||||
const storageRoot = resolveStorageRoot();
|
process.stderr.write(`Error: Command 'thread step-details' has been removed.
|
||||||
runAction(async () => {
|
Use 'step show' instead.
|
||||||
const detail = await cmdThreadStepDetails(storageRoot, stepHash);
|
|
||||||
process.stdout.write(yamlStringify(detail));
|
For more information, see: uwf help step show
|
||||||
});
|
`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("fork")
|
||||||
|
.description("[DEPRECATED] Use 'step fork' instead")
|
||||||
|
.argument("<step-hash>", "Step hash")
|
||||||
|
.action(() => {
|
||||||
|
process.stderr.write(`Error: Command 'thread fork' has been removed.
|
||||||
|
Use 'step fork' instead.
|
||||||
|
|
||||||
|
For more information, see: uwf help step fork
|
||||||
|
`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("kill")
|
||||||
|
.description("[DEPRECATED] Use 'thread stop' or 'thread cancel' instead")
|
||||||
|
.argument("<thread-id>", "Thread ULID")
|
||||||
|
.action(() => {
|
||||||
|
process.stderr.write(`Error: Command 'thread kill' has been removed.
|
||||||
|
Use 'thread stop' to stop background execution (keep thread active),
|
||||||
|
or 'thread cancel' to cancel and archive the thread.
|
||||||
|
|
||||||
|
For more information, see:
|
||||||
|
uwf help thread stop
|
||||||
|
uwf help thread cancel
|
||||||
|
`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("running")
|
||||||
|
.description("[DEPRECATED] Use 'thread list --status running' instead")
|
||||||
|
.action(() => {
|
||||||
|
process.stderr.write(`Error: Command 'thread running' has been removed.
|
||||||
|
Use 'thread list --status running' instead.
|
||||||
|
|
||||||
|
For more information, see: uwf help thread list
|
||||||
|
`);
|
||||||
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
const skill = program.command("skill").description("Built-in skill references for agents");
|
const skill = program.command("skill").description("Built-in skill references for agents");
|
||||||
@@ -321,7 +565,11 @@ cas
|
|||||||
.action((hash: string) => {
|
.action((hash: string) => {
|
||||||
const storageRoot = resolveStorageRoot();
|
const storageRoot = resolveStorageRoot();
|
||||||
runAction(async () => {
|
runAction(async () => {
|
||||||
writeOutput(await cmdCasHas(storageRoot, hash));
|
const result = await cmdCasHas(storageRoot, hash);
|
||||||
|
writeOutput(result);
|
||||||
|
if (!result.exists) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import type { Store as CasStore, JSONSchema } from "@uncaged/json-cas";
|
||||||
|
import { getSchema } from "@uncaged/json-cas";
|
||||||
|
import type {
|
||||||
|
CasRef,
|
||||||
|
StartNodePayload,
|
||||||
|
StepNodePayload,
|
||||||
|
ThreadId,
|
||||||
|
} from "@uncaged/workflow-protocol";
|
||||||
|
import { findThreadInHistory, loadThreadsIndex, type UwfStore } from "../store.js";
|
||||||
|
|
||||||
|
type ChainState = {
|
||||||
|
startHash: CasRef;
|
||||||
|
start: StartNodePayload;
|
||||||
|
stepsNewestFirst: StepNodePayload[];
|
||||||
|
headIsStart: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OrderedStepItem = {
|
||||||
|
hash: CasRef;
|
||||||
|
payload: StepNodePayload;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
process.stderr.write(`${message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkChain(uwf: UwfStore, headHash: CasRef): ChainState {
|
||||||
|
const headNode = uwf.store.get(headHash);
|
||||||
|
if (headNode === null) {
|
||||||
|
fail(`CAS node not found: ${headHash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headNode.type === uwf.schemas.startNode) {
|
||||||
|
return {
|
||||||
|
startHash: headHash,
|
||||||
|
start: headNode.payload as StartNodePayload,
|
||||||
|
stepsNewestFirst: [],
|
||||||
|
headIsStart: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headNode.type !== uwf.schemas.stepNode) {
|
||||||
|
fail(`head ${headHash} is not a StartNode or StepNode`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepsNewestFirst: StepNodePayload[] = [];
|
||||||
|
let hash: CasRef | null = headHash;
|
||||||
|
|
||||||
|
while (hash !== null) {
|
||||||
|
const node = uwf.store.get(hash);
|
||||||
|
if (node === null) {
|
||||||
|
fail(`CAS node not found while walking chain: ${hash}`);
|
||||||
|
}
|
||||||
|
if (node.type !== uwf.schemas.stepNode) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const payload = node.payload as StepNodePayload;
|
||||||
|
stepsNewestFirst.push(payload);
|
||||||
|
hash = payload.prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newest = stepsNewestFirst[0];
|
||||||
|
if (newest === undefined) {
|
||||||
|
fail(`empty step chain at head ${headHash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startNode = uwf.store.get(newest.start);
|
||||||
|
if (startNode === null || startNode.type !== uwf.schemas.startNode) {
|
||||||
|
fail(`StartNode not found: ${newest.start}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startHash: newest.start,
|
||||||
|
start: startNode.payload as StartNodePayload,
|
||||||
|
stepsNewestFirst,
|
||||||
|
headIsStart: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandOutput(uwf: UwfStore, outputRef: CasRef): unknown {
|
||||||
|
const node = uwf.store.get(outputRef);
|
||||||
|
if (node === null) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return node.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively expand all cas_ref fields in a CAS node's payload,
|
||||||
|
* replacing hash strings with the referenced node's expanded payload.
|
||||||
|
*/
|
||||||
|
function expandDeep(store: CasStore, hash: CasRef, visited?: Set<string>): unknown {
|
||||||
|
const seen = visited ?? new Set<string>();
|
||||||
|
if (seen.has(hash)) return hash; // cycle guard
|
||||||
|
seen.add(hash);
|
||||||
|
|
||||||
|
const node = store.get(hash);
|
||||||
|
if (node === null) return hash;
|
||||||
|
|
||||||
|
const schema = getSchema(store, node.type);
|
||||||
|
if (schema === null) return node.payload;
|
||||||
|
|
||||||
|
return expandValue(store, schema, node.payload, seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandCasRefField(store: CasStore, value: unknown, visited: Set<string>): unknown {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return expandDeep(store, value as CasRef, visited);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandAnyOfField(
|
||||||
|
store: CasStore,
|
||||||
|
schema: JSONSchema,
|
||||||
|
value: unknown,
|
||||||
|
visited: Set<string>,
|
||||||
|
): unknown {
|
||||||
|
if (!Array.isArray(schema.anyOf)) return value;
|
||||||
|
for (const sub of schema.anyOf as JSONSchema[]) {
|
||||||
|
if (sub.format === "cas_ref" && typeof value === "string") {
|
||||||
|
return expandDeep(store, value as CasRef, visited);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandArrayField(
|
||||||
|
store: CasStore,
|
||||||
|
schema: JSONSchema,
|
||||||
|
value: unknown,
|
||||||
|
visited: Set<string>,
|
||||||
|
): unknown {
|
||||||
|
if (!schema.items || !Array.isArray(value)) return value;
|
||||||
|
const itemSchema = schema.items as JSONSchema;
|
||||||
|
return (value as unknown[]).map((item) => expandValue(store, itemSchema, item, visited));
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandObjectField(
|
||||||
|
store: CasStore,
|
||||||
|
schema: JSONSchema,
|
||||||
|
value: unknown,
|
||||||
|
visited: Set<string>,
|
||||||
|
): unknown {
|
||||||
|
if (value === null || typeof value !== "object" || Array.isArray(value) || !schema.properties) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const props = schema.properties as Record<string, JSONSchema>;
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [key, val] of Object.entries(obj)) {
|
||||||
|
const propSchema = props[key];
|
||||||
|
result[key] = propSchema ? expandValue(store, propSchema, val, visited) : val;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandValue(
|
||||||
|
store: CasStore,
|
||||||
|
schema: JSONSchema,
|
||||||
|
value: unknown,
|
||||||
|
visited: Set<string>,
|
||||||
|
): unknown {
|
||||||
|
if (schema.format === "cas_ref") return expandCasRefField(store, value, visited);
|
||||||
|
if (Array.isArray(schema.anyOf)) return expandAnyOfField(store, schema, value, visited);
|
||||||
|
if (schema.type === "array") return expandArrayField(store, schema, value, visited);
|
||||||
|
return expandObjectField(store, schema, value, visited);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectOrderedSteps(
|
||||||
|
uwf: UwfStore,
|
||||||
|
headHash: CasRef,
|
||||||
|
chain: ChainState,
|
||||||
|
): OrderedStepItem[] {
|
||||||
|
let hash: CasRef | null = headHash;
|
||||||
|
const hashToNode = new Map<string, { payload: StepNodePayload; timestamp: number }>();
|
||||||
|
while (hash !== null) {
|
||||||
|
const node = uwf.store.get(hash);
|
||||||
|
if (node === null || node.type !== uwf.schemas.stepNode) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const payload = node.payload as StepNodePayload;
|
||||||
|
hashToNode.set(hash, { payload, timestamp: node.timestamp });
|
||||||
|
hash = payload.prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cur: CasRef | null = chain.headIsStart ? null : headHash;
|
||||||
|
const ordered: OrderedStepItem[] = [];
|
||||||
|
while (cur !== null) {
|
||||||
|
const entry = hashToNode.get(cur);
|
||||||
|
if (entry === undefined) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ordered.push({ hash: cur, ...entry });
|
||||||
|
cur = entry.payload.prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
ordered.reverse();
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveHeadHash(storageRoot: string, threadId: ThreadId): Promise<CasRef> {
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
const activeHead = index[threadId];
|
||||||
|
if (activeHead !== undefined) {
|
||||||
|
return activeHead;
|
||||||
|
}
|
||||||
|
const hist = await findThreadInHistory(storageRoot, threadId);
|
||||||
|
if (hist !== null) {
|
||||||
|
return hist.head;
|
||||||
|
}
|
||||||
|
fail(`thread not found: ${threadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
type ChainState,
|
||||||
|
collectOrderedSteps,
|
||||||
|
expandAnyOfField,
|
||||||
|
expandArrayField,
|
||||||
|
expandCasRefField,
|
||||||
|
expandDeep,
|
||||||
|
expandObjectField,
|
||||||
|
expandOutput,
|
||||||
|
expandValue,
|
||||||
|
fail,
|
||||||
|
type OrderedStepItem,
|
||||||
|
resolveHeadHash,
|
||||||
|
walkChain,
|
||||||
|
};
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import type {
|
||||||
|
CasRef,
|
||||||
|
StartEntry,
|
||||||
|
StepEntry,
|
||||||
|
StepNodePayload,
|
||||||
|
ThreadForkOutput,
|
||||||
|
ThreadId,
|
||||||
|
ThreadStepsOutput,
|
||||||
|
} from "@uncaged/workflow-protocol";
|
||||||
|
import { generateUlid } from "@uncaged/workflow-util";
|
||||||
|
import { createUwfStore, loadThreadsIndex, saveThreadsIndex } from "../store.js";
|
||||||
|
import {
|
||||||
|
collectOrderedSteps,
|
||||||
|
expandDeep,
|
||||||
|
expandOutput,
|
||||||
|
fail,
|
||||||
|
resolveHeadHash,
|
||||||
|
walkChain,
|
||||||
|
} from "./shared.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all steps in a thread (previously: thread steps)
|
||||||
|
*/
|
||||||
|
export async function cmdStepList(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: ThreadId,
|
||||||
|
): Promise<ThreadStepsOutput> {
|
||||||
|
const headHash = await resolveHeadHash(storageRoot, threadId);
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const chain = walkChain(uwf, headHash);
|
||||||
|
|
||||||
|
const startNode = uwf.store.get(chain.startHash);
|
||||||
|
if (startNode === null) {
|
||||||
|
fail(`StartNode not found: ${chain.startHash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startEntry: StartEntry = {
|
||||||
|
hash: chain.startHash,
|
||||||
|
workflow: chain.start.workflow,
|
||||||
|
prompt: chain.start.prompt,
|
||||||
|
timestamp: startNode.timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepEntries: StepEntry[] = [];
|
||||||
|
const ordered = collectOrderedSteps(uwf, headHash, chain);
|
||||||
|
|
||||||
|
for (const item of ordered) {
|
||||||
|
stepEntries.push({
|
||||||
|
hash: item.hash,
|
||||||
|
role: item.payload.role,
|
||||||
|
output: expandOutput(uwf, item.payload.output),
|
||||||
|
detail: item.payload.detail ?? null,
|
||||||
|
agent: item.payload.agent,
|
||||||
|
timestamp: item.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
thread: threadId,
|
||||||
|
workflow: chain.start.workflow,
|
||||||
|
steps: [startEntry, ...stepEntries],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show details of a specific step (previously: thread step-details)
|
||||||
|
*/
|
||||||
|
export async function cmdStepShow(storageRoot: string, stepHash: CasRef): Promise<unknown> {
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const node = uwf.store.get(stepHash);
|
||||||
|
if (node === null) {
|
||||||
|
fail(`CAS node not found: ${stepHash}`);
|
||||||
|
}
|
||||||
|
if (node.type !== uwf.schemas.stepNode) {
|
||||||
|
fail(`node ${stepHash} is not a StepNode`);
|
||||||
|
}
|
||||||
|
const payload = node.payload as StepNodePayload;
|
||||||
|
if (!payload.detail) {
|
||||||
|
fail(`step ${stepHash} has no detail`);
|
||||||
|
}
|
||||||
|
return expandDeep(uwf.store, payload.detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fork a thread from a specific step (previously: thread fork)
|
||||||
|
*/
|
||||||
|
export async function cmdStepFork(
|
||||||
|
storageRoot: string,
|
||||||
|
stepHash: CasRef,
|
||||||
|
): Promise<ThreadForkOutput> {
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const node = uwf.store.get(stepHash);
|
||||||
|
if (node === null) {
|
||||||
|
fail(`CAS node not found: ${stepHash}`);
|
||||||
|
}
|
||||||
|
if (node.type !== uwf.schemas.startNode && node.type !== uwf.schemas.stepNode) {
|
||||||
|
fail(`node ${stepHash} is not a StartNode or StepNode`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newThreadId = generateUlid(Date.now()) as ThreadId;
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
index[newThreadId] = stepHash;
|
||||||
|
await saveThreadsIndex(storageRoot, index);
|
||||||
|
|
||||||
|
return {
|
||||||
|
thread: newThreadId,
|
||||||
|
forkedFrom: {
|
||||||
|
step: stepHash,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a step's agent turns as human-readable markdown with quota enforcement
|
||||||
|
*/
|
||||||
|
export async function cmdStepRead(
|
||||||
|
storageRoot: string,
|
||||||
|
stepHash: CasRef,
|
||||||
|
quota: number,
|
||||||
|
): Promise<string> {
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const node = uwf.store.get(stepHash);
|
||||||
|
if (node === null) {
|
||||||
|
fail(`CAS node not found: ${stepHash}`);
|
||||||
|
}
|
||||||
|
if (node.type !== uwf.schemas.stepNode) {
|
||||||
|
fail(`node ${stepHash} is not a StepNode`);
|
||||||
|
}
|
||||||
|
const payload = node.payload as StepNodePayload;
|
||||||
|
|
||||||
|
// Build header section
|
||||||
|
const parts: string[] = [];
|
||||||
|
parts.push(`# Step ${stepHash}`);
|
||||||
|
parts.push("");
|
||||||
|
parts.push(`**Role:** ${payload.role}`);
|
||||||
|
parts.push(`**Agent:** ${payload.agent}`);
|
||||||
|
|
||||||
|
// If no detail, return metadata only
|
||||||
|
if (payload.detail === null) {
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load detail node
|
||||||
|
const detailNode = uwf.store.get(payload.detail);
|
||||||
|
if (detailNode === null) {
|
||||||
|
fail(`detail node not found: ${payload.detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = detailNode.payload as Record<string, unknown>;
|
||||||
|
const turns = detail.turns;
|
||||||
|
|
||||||
|
// If no turns array, return metadata only
|
||||||
|
if (!Array.isArray(turns) || turns.length === 0) {
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all turn nodes
|
||||||
|
type TurnData = {
|
||||||
|
index: number;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
const turnData: TurnData[] = [];
|
||||||
|
for (const turnRef of turns) {
|
||||||
|
if (typeof turnRef !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const turnNode = uwf.store.get(turnRef as CasRef);
|
||||||
|
if (turnNode === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const turn = turnNode.payload as Record<string, unknown>;
|
||||||
|
if (typeof turn.content === "string") {
|
||||||
|
turnData.push({
|
||||||
|
index: typeof turn.index === "number" ? turn.index : turnData.length,
|
||||||
|
content: turn.content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (turnData.length === 0) {
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate header length for quota accounting
|
||||||
|
const headerSection = parts.join("\n");
|
||||||
|
const headerLength = headerSection.length;
|
||||||
|
|
||||||
|
// Select turns that fit within quota (working backwards from most recent)
|
||||||
|
const BUFFER = 200; // Conservative buffer for structural overhead
|
||||||
|
const availableQuota = quota - headerLength - BUFFER;
|
||||||
|
|
||||||
|
const selectedTurns: TurnData[] = [];
|
||||||
|
let totalChars = 0;
|
||||||
|
|
||||||
|
for (let i = turnData.length - 1; i >= 0; i--) {
|
||||||
|
const turn = turnData[i];
|
||||||
|
if (turn === undefined) continue;
|
||||||
|
|
||||||
|
// Calculate formatted turn length
|
||||||
|
const turnHeader = `## Turn ${turn.index + 1}\n\n`;
|
||||||
|
const turnBlock = turnHeader + turn.content;
|
||||||
|
const separatorCost = selectedTurns.length > 0 ? 2 : 0; // "\n\n" between turns
|
||||||
|
const addCost = turnBlock.length + separatorCost;
|
||||||
|
|
||||||
|
// Check quota - but always include at least one turn
|
||||||
|
if (totalChars + addCost > availableQuota && selectedTurns.length > 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedTurns.unshift(turn);
|
||||||
|
totalChars += addCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add skip hint if not all turns fit
|
||||||
|
const skippedCount = turnData.length - selectedTurns.length;
|
||||||
|
if (skippedCount > 0) {
|
||||||
|
parts.push("");
|
||||||
|
parts.push(`_[Earlier turns omitted due to quota. Use --quota to increase.]_`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add selected turns
|
||||||
|
for (const turn of selectedTurns) {
|
||||||
|
parts.push("");
|
||||||
|
parts.push(`## Turn ${turn.index + 1}`);
|
||||||
|
parts.push("");
|
||||||
|
parts.push(turn.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Parse time input: ISO date (YYYY-MM-DD, YYYY-MM-DDTHH:MM:SS) or relative (7d, 24h, 30m)
|
||||||
|
* Returns Unix timestamp in milliseconds.
|
||||||
|
*/
|
||||||
|
export function parseTimeInput(input: string, nowMs: number): number {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
|
||||||
|
// Relative time: 7d, 24h, 30m
|
||||||
|
const relativeMatch = /^(\d+)(d|h|m)$/.exec(trimmed);
|
||||||
|
if (relativeMatch !== null) {
|
||||||
|
const value = Number.parseInt(relativeMatch[1], 10);
|
||||||
|
const unit = relativeMatch[2];
|
||||||
|
const multiplier = unit === "d" ? 86400000 : unit === "h" ? 3600000 : 60000;
|
||||||
|
return nowMs - value * multiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISO date: try parsing
|
||||||
|
const parsed = Date.parse(trimmed);
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
throw new Error(`invalid time format: ${trimmed} (expected ISO date or relative like '7d')`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync, spawn } from "node:child_process";
|
||||||
import { access, readFile } from "node:fs/promises";
|
import { access, readFile } from "node:fs/promises";
|
||||||
import { dirname, isAbsolute, resolve as resolvePath } from "node:path";
|
import { dirname, isAbsolute, resolve as resolvePath } from "node:path";
|
||||||
import type { Store as CasStore, JSONSchema } from "@uncaged/json-cas";
|
import { validate } from "@uncaged/json-cas";
|
||||||
import { getSchema, validate } from "@uncaged/json-cas";
|
|
||||||
import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-agent-kit";
|
import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-agent-kit";
|
||||||
import { evaluate } from "@uncaged/workflow-moderator";
|
import { evaluate } from "@uncaged/workflow-moderator";
|
||||||
import type {
|
import type {
|
||||||
@@ -10,24 +9,26 @@ import type {
|
|||||||
AgentConfig,
|
AgentConfig,
|
||||||
CasRef,
|
CasRef,
|
||||||
ModeratorContext,
|
ModeratorContext,
|
||||||
StartEntry,
|
|
||||||
StartNodePayload,
|
StartNodePayload,
|
||||||
StartOutput,
|
StartOutput,
|
||||||
StepContext,
|
StepContext,
|
||||||
StepEntry,
|
|
||||||
StepNodePayload,
|
StepNodePayload,
|
||||||
StepOutput,
|
StepOutput,
|
||||||
ThreadForkOutput,
|
|
||||||
ThreadId,
|
ThreadId,
|
||||||
ThreadListItem,
|
ThreadListItem,
|
||||||
ThreadStepsOutput,
|
ThreadsIndex,
|
||||||
WorkflowConfig,
|
WorkflowConfig,
|
||||||
WorkflowPayload,
|
WorkflowPayload,
|
||||||
} from "@uncaged/workflow-protocol";
|
} from "@uncaged/workflow-protocol";
|
||||||
import { createProcessLogger, generateUlid, type ProcessLogger } from "@uncaged/workflow-util";
|
import {
|
||||||
|
createProcessLogger,
|
||||||
|
extractUlidTimestamp,
|
||||||
|
generateUlid,
|
||||||
|
type ProcessLogger,
|
||||||
|
} from "@uncaged/workflow-util";
|
||||||
import { config as loadDotenv } from "dotenv";
|
import { config as loadDotenv } from "dotenv";
|
||||||
import { parse, stringify } from "yaml";
|
import { parse } from "yaml";
|
||||||
|
import { createMarker, deleteMarker, isThreadRunning } from "../background/index.js";
|
||||||
import {
|
import {
|
||||||
appendThreadHistory,
|
appendThreadHistory,
|
||||||
createUwfStore,
|
createUwfStore,
|
||||||
@@ -41,6 +42,14 @@ import {
|
|||||||
type UwfStore,
|
type UwfStore,
|
||||||
} from "../store.js";
|
} from "../store.js";
|
||||||
import { checkWorkflowFilenameConsistency, isCasRef, parseWorkflowPayload } from "../validate.js";
|
import { checkWorkflowFilenameConsistency, isCasRef, parseWorkflowPayload } from "../validate.js";
|
||||||
|
import {
|
||||||
|
type ChainState,
|
||||||
|
collectOrderedSteps,
|
||||||
|
expandOutput,
|
||||||
|
fail,
|
||||||
|
type OrderedStepItem,
|
||||||
|
walkChain,
|
||||||
|
} from "./shared.js";
|
||||||
import { materializeWorkflowPayload } from "./workflow.js";
|
import { materializeWorkflowPayload } from "./workflow.js";
|
||||||
|
|
||||||
const END_ROLE = "$END";
|
const END_ROLE = "$END";
|
||||||
@@ -52,35 +61,13 @@ const PL_AGENT_SPAWN = "R5J2W8N4";
|
|||||||
const PL_AGENT_DONE = "C6P9E3H7";
|
const PL_AGENT_DONE = "C6P9E3H7";
|
||||||
const PL_THREAD_ARCHIVED = "F4D8Q2K5";
|
const PL_THREAD_ARCHIVED = "F4D8Q2K5";
|
||||||
const PL_STEP_ERROR = "B8T5N1V6";
|
const PL_STEP_ERROR = "B8T5N1V6";
|
||||||
|
const PL_BACKGROUND_START = "X7Q4W9M2";
|
||||||
|
|
||||||
function failStep(plog: ProcessLogger, message: string): never {
|
function failStep(plog: ProcessLogger, message: string): never {
|
||||||
plog.log(PL_STEP_ERROR, message, null);
|
plog.log(PL_STEP_ERROR, message, null);
|
||||||
fail(message);
|
fail(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChainState = {
|
|
||||||
startHash: CasRef;
|
|
||||||
start: StartNodePayload;
|
|
||||||
stepsNewestFirst: StepNodePayload[];
|
|
||||||
headIsStart: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type OrderedStepItem = {
|
|
||||||
hash: CasRef;
|
|
||||||
payload: StepNodePayload;
|
|
||||||
timestamp: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type KillOutput = {
|
|
||||||
thread: ThreadId;
|
|
||||||
archived: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function fail(message: string): never {
|
|
||||||
process.stderr.write(`${message}\n`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a string looks like a file path (contains path separators or has .yaml/.yml extension).
|
* Check if a string looks like a file path (contains path separators or has .yaml/.yml extension).
|
||||||
*/
|
*/
|
||||||
@@ -321,6 +308,7 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
|
|||||||
thread: threadId,
|
thread: threadId,
|
||||||
head: activeHead,
|
head: activeHead,
|
||||||
done: false,
|
done: false,
|
||||||
|
background: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,249 +319,146 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
|
|||||||
thread: threadId,
|
thread: threadId,
|
||||||
head: hist.head,
|
head: hist.head,
|
||||||
done: true,
|
done: true,
|
||||||
|
background: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
fail(`thread not found: ${threadId}`);
|
fail(`thread not found: ${threadId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ThreadStatus = "idle" | "running" | "completed";
|
||||||
|
|
||||||
|
export type ThreadListItemWithStatus = ThreadListItem & {
|
||||||
|
status: ThreadStatus;
|
||||||
|
};
|
||||||
|
|
||||||
async function threadListItemFromActive(
|
async function threadListItemFromActive(
|
||||||
|
storageRoot: string,
|
||||||
uwf: UwfStore,
|
uwf: UwfStore,
|
||||||
threadId: ThreadId,
|
threadId: ThreadId,
|
||||||
head: CasRef,
|
head: CasRef,
|
||||||
): Promise<ThreadListItem | null> {
|
): Promise<ThreadListItemWithStatus | null> {
|
||||||
const workflow = resolveWorkflowFromHead(uwf, head);
|
const workflow = resolveWorkflowFromHead(uwf, head);
|
||||||
if (workflow === null) {
|
if (workflow === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return { thread: threadId, workflow, head };
|
|
||||||
|
// Check if thread is currently running in background
|
||||||
|
const runningMarker = await isThreadRunning(storageRoot, threadId);
|
||||||
|
const status: ThreadStatus = runningMarker !== null ? "running" : "idle";
|
||||||
|
|
||||||
|
return { thread: threadId, workflow, head, status };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cmdThreadList(
|
async function collectActiveThreads(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
includeAll: boolean,
|
uwf: UwfStore,
|
||||||
): Promise<ThreadListItem[]> {
|
index: ThreadsIndex,
|
||||||
const uwf = await createUwfStore(storageRoot);
|
): Promise<ThreadListItemWithStatus[]> {
|
||||||
const index = await loadThreadsIndex(storageRoot);
|
const items: ThreadListItemWithStatus[] = [];
|
||||||
const items: ThreadListItem[] = [];
|
|
||||||
|
|
||||||
for (const [threadId, head] of Object.entries(index)) {
|
for (const [threadId, head] of Object.entries(index)) {
|
||||||
const item = await threadListItemFromActive(uwf, threadId as ThreadId, head);
|
const item = await threadListItemFromActive(
|
||||||
|
storageRoot,
|
||||||
|
uwf,
|
||||||
|
threadId as ThreadId,
|
||||||
|
head as CasRef,
|
||||||
|
);
|
||||||
if (item !== null) {
|
if (item !== null) {
|
||||||
items.push(item);
|
items.push(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
if (!includeAll) {
|
async function collectCompletedThreads(
|
||||||
return items;
|
storageRoot: string,
|
||||||
}
|
activeIds: Set<ThreadId>,
|
||||||
|
): Promise<ThreadListItemWithStatus[]> {
|
||||||
const activeIds = new Set(items.map((i) => i.thread));
|
const items: ThreadListItemWithStatus[] = [];
|
||||||
const history = await loadThreadHistory(storageRoot);
|
const history = await loadThreadHistory(storageRoot);
|
||||||
|
const seen = new Set<ThreadId>(); // Deduplication (issue #470)
|
||||||
for (const entry of history) {
|
for (const entry of history) {
|
||||||
if (!activeIds.has(entry.thread)) {
|
if (!activeIds.has(entry.thread) && !seen.has(entry.thread)) {
|
||||||
|
seen.add(entry.thread);
|
||||||
items.push({
|
items.push({
|
||||||
thread: entry.thread,
|
thread: entry.thread,
|
||||||
workflow: entry.workflow,
|
workflow: entry.workflow,
|
||||||
head: entry.head,
|
head: entry.head,
|
||||||
|
status: "completed",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
function walkChain(uwf: UwfStore, headHash: CasRef): ChainState {
|
function applyTimeFilters(
|
||||||
const headNode = uwf.store.get(headHash);
|
items: ThreadListItemWithStatus[],
|
||||||
if (headNode === null) {
|
afterMs: number | null,
|
||||||
fail(`CAS node not found: ${headHash}`);
|
beforeMs: number | null,
|
||||||
}
|
): ThreadListItemWithStatus[] {
|
||||||
|
if (afterMs === null && beforeMs === null) return items;
|
||||||
if (headNode.type === uwf.schemas.startNode) {
|
return items.filter((item) => {
|
||||||
return {
|
const ts = extractUlidTimestamp(item.thread);
|
||||||
startHash: headHash,
|
if (ts === null) return false;
|
||||||
start: headNode.payload as StartNodePayload,
|
if (afterMs !== null && ts <= afterMs) return false;
|
||||||
stepsNewestFirst: [],
|
if (beforeMs !== null && ts >= beforeMs) return false;
|
||||||
headIsStart: true,
|
return true;
|
||||||
};
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (headNode.type !== uwf.schemas.stepNode) {
|
|
||||||
fail(`head ${headHash} is not a StartNode or StepNode`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stepsNewestFirst: StepNodePayload[] = [];
|
|
||||||
let hash: CasRef | null = headHash;
|
|
||||||
|
|
||||||
while (hash !== null) {
|
|
||||||
const node = uwf.store.get(hash);
|
|
||||||
if (node === null) {
|
|
||||||
fail(`CAS node not found while walking chain: ${hash}`);
|
|
||||||
}
|
|
||||||
if (node.type !== uwf.schemas.stepNode) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const payload = node.payload as StepNodePayload;
|
|
||||||
stepsNewestFirst.push(payload);
|
|
||||||
hash = payload.prev;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newest = stepsNewestFirst[0];
|
|
||||||
if (newest === undefined) {
|
|
||||||
fail(`empty step chain at head ${headHash}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const startNode = uwf.store.get(newest.start);
|
|
||||||
if (startNode === null || startNode.type !== uwf.schemas.startNode) {
|
|
||||||
fail(`StartNode not found: ${newest.start}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
startHash: newest.start,
|
|
||||||
start: startNode.payload as StartNodePayload,
|
|
||||||
stepsNewestFirst,
|
|
||||||
headIsStart: false,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function expandOutput(uwf: UwfStore, outputRef: CasRef): unknown {
|
function sortByNewestFirst(items: ThreadListItemWithStatus[]): ThreadListItemWithStatus[] {
|
||||||
const node = uwf.store.get(outputRef);
|
return items.sort((a, b) => {
|
||||||
if (node === null) {
|
const tsA = extractUlidTimestamp(a.thread) ?? 0;
|
||||||
return {};
|
const tsB = extractUlidTimestamp(b.thread) ?? 0;
|
||||||
}
|
return tsB - tsA;
|
||||||
return node.payload;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function applyPagination(
|
||||||
* Recursively expand all cas_ref fields in a CAS node's payload,
|
items: ThreadListItemWithStatus[],
|
||||||
* replacing hash strings with the referenced node's expanded payload.
|
skip: number | null,
|
||||||
*/
|
take: number | null,
|
||||||
function expandDeep(store: CasStore, hash: CasRef, visited?: Set<string>): unknown {
|
): ThreadListItemWithStatus[] {
|
||||||
const seen = visited ?? new Set<string>();
|
const skipCount = skip ?? 0;
|
||||||
if (seen.has(hash)) return hash; // cycle guard
|
const takeCount = take ?? items.length;
|
||||||
seen.add(hash);
|
return items.slice(skipCount, skipCount + takeCount);
|
||||||
|
|
||||||
const node = store.get(hash);
|
|
||||||
if (node === null) return hash;
|
|
||||||
|
|
||||||
const schema = getSchema(store, node.type);
|
|
||||||
if (schema === null) return node.payload;
|
|
||||||
|
|
||||||
return expandValue(store, schema, node.payload, seen);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function expandCasRefField(store: CasStore, value: unknown, visited: Set<string>): unknown {
|
export async function cmdThreadList(
|
||||||
if (typeof value === "string") {
|
storageRoot: string,
|
||||||
return expandDeep(store, value as CasRef, visited);
|
statusFilter: ThreadStatus[] | null,
|
||||||
}
|
afterMs: number | null,
|
||||||
return value;
|
beforeMs: number | null,
|
||||||
}
|
skip: number | null,
|
||||||
|
take: number | null,
|
||||||
|
): Promise<ThreadListItemWithStatus[]> {
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
|
||||||
function expandAnyOfField(
|
// Collect active threads
|
||||||
store: CasStore,
|
let items = await collectActiveThreads(storageRoot, uwf, index);
|
||||||
schema: JSONSchema,
|
|
||||||
value: unknown,
|
|
||||||
visited: Set<string>,
|
|
||||||
): unknown {
|
|
||||||
if (!Array.isArray(schema.anyOf)) return value;
|
|
||||||
for (const sub of schema.anyOf as JSONSchema[]) {
|
|
||||||
if (sub.format === "cas_ref" && typeof value === "string") {
|
|
||||||
return expandDeep(store, value as CasRef, visited);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function expandArrayField(
|
// Collect completed threads (if relevant for status filter)
|
||||||
store: CasStore,
|
const includeCompleted = statusFilter === null || statusFilter.includes("completed");
|
||||||
schema: JSONSchema,
|
if (includeCompleted) {
|
||||||
value: unknown,
|
const activeIds = new Set(items.map((i) => i.thread));
|
||||||
visited: Set<string>,
|
const completedItems = await collectCompletedThreads(storageRoot, activeIds);
|
||||||
): unknown {
|
items = items.concat(completedItems);
|
||||||
if (!schema.items || !Array.isArray(value)) return value;
|
|
||||||
const itemSchema = schema.items as JSONSchema;
|
|
||||||
return (value as unknown[]).map((item) => expandValue(store, itemSchema, item, visited));
|
|
||||||
}
|
|
||||||
|
|
||||||
function expandObjectField(
|
|
||||||
store: CasStore,
|
|
||||||
schema: JSONSchema,
|
|
||||||
value: unknown,
|
|
||||||
visited: Set<string>,
|
|
||||||
): unknown {
|
|
||||||
if (value === null || typeof value !== "object" || Array.isArray(value) || !schema.properties) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
const props = schema.properties as Record<string, JSONSchema>;
|
|
||||||
const obj = value as Record<string, unknown>;
|
|
||||||
const result: Record<string, unknown> = {};
|
|
||||||
for (const [key, val] of Object.entries(obj)) {
|
|
||||||
const propSchema = props[key];
|
|
||||||
result[key] = propSchema ? expandValue(store, propSchema, val, visited) : val;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function expandValue(
|
|
||||||
store: CasStore,
|
|
||||||
schema: JSONSchema,
|
|
||||||
value: unknown,
|
|
||||||
visited: Set<string>,
|
|
||||||
): unknown {
|
|
||||||
if (schema.format === "cas_ref") return expandCasRefField(store, value, visited);
|
|
||||||
if (Array.isArray(schema.anyOf)) return expandAnyOfField(store, schema, value, visited);
|
|
||||||
if (schema.type === "array") return expandArrayField(store, schema, value, visited);
|
|
||||||
return expandObjectField(store, schema, value, visited);
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectOrderedSteps(
|
|
||||||
uwf: UwfStore,
|
|
||||||
headHash: CasRef,
|
|
||||||
chain: ChainState,
|
|
||||||
): OrderedStepItem[] {
|
|
||||||
let hash: CasRef | null = headHash;
|
|
||||||
const hashToNode = new Map<string, { payload: StepNodePayload; timestamp: number }>();
|
|
||||||
while (hash !== null) {
|
|
||||||
const node = uwf.store.get(hash);
|
|
||||||
if (node === null || node.type !== uwf.schemas.stepNode) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const payload = node.payload as StepNodePayload;
|
|
||||||
hashToNode.set(hash, { payload, timestamp: node.timestamp });
|
|
||||||
hash = payload.prev;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let cur: CasRef | null = chain.headIsStart ? null : headHash;
|
// Apply status filter
|
||||||
const ordered: OrderedStepItem[] = [];
|
if (statusFilter !== null) {
|
||||||
while (cur !== null) {
|
items = items.filter((item) => statusFilter.includes(item.status));
|
||||||
const entry = hashToNode.get(cur);
|
|
||||||
if (entry === undefined) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
ordered.push({ hash: cur, ...entry });
|
|
||||||
cur = entry.payload.prev;
|
|
||||||
}
|
}
|
||||||
ordered.reverse();
|
|
||||||
return ordered;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatYaml(value: unknown): string {
|
// Apply time range filters
|
||||||
return stringify(value, { aliasDuplicateObjects: false }).trimEnd();
|
items = applyTimeFilters(items, afterMs, beforeMs);
|
||||||
}
|
|
||||||
|
|
||||||
function formatCompactStep(index: number, item: OrderedStepItem, outputYaml: string): string {
|
// Sort by timestamp descending (newest first)
|
||||||
return [
|
items = sortByNewestFirst(items);
|
||||||
`## Step ${index}: ${item.payload.role}`,
|
|
||||||
"",
|
// Apply pagination
|
||||||
`- **Hash:** \`${item.hash}\``,
|
return applyPagination(items, skip, take);
|
||||||
`- **Agent:** ${item.payload.agent}`,
|
|
||||||
"",
|
|
||||||
"### Output",
|
|
||||||
"",
|
|
||||||
"```yaml",
|
|
||||||
outputYaml,
|
|
||||||
"```",
|
|
||||||
].join("\n");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractLastAssistantContent(uwf: UwfStore, detailRef: CasRef): string | null {
|
export function extractLastAssistantContent(uwf: UwfStore, detailRef: CasRef): string | null {
|
||||||
@@ -619,22 +504,60 @@ function sliceBeforeHash(
|
|||||||
return candidates.slice(0, idx);
|
return candidates.slice(0, idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function calculateFormattedStepLength(
|
||||||
|
stepNum: number,
|
||||||
|
item: OrderedStepItem,
|
||||||
|
uwf: UwfStore,
|
||||||
|
workflow: WorkflowPayload,
|
||||||
|
): number {
|
||||||
|
// Calculate using the same format as formatStepHeader, formatStepPrompt, formatStepContent
|
||||||
|
// Use a temporary set to avoid mutating the actual shownPromptRoles during calculation
|
||||||
|
const tempShownRoles = new Set<string>();
|
||||||
|
const header = formatStepHeader(stepNum, item);
|
||||||
|
const roleDef = workflow.roles[item.payload.role];
|
||||||
|
const prompt = formatStepPrompt(roleDef, item.payload.role, tempShownRoles);
|
||||||
|
const content = formatStepContent(uwf, item);
|
||||||
|
|
||||||
|
const stepBlock = [header, prompt, content].filter((s) => s !== "").join("");
|
||||||
|
|
||||||
|
// Don't add separator here - it will be counted when we know the final structure
|
||||||
|
return stepBlock.length;
|
||||||
|
}
|
||||||
|
|
||||||
function selectByQuota(
|
function selectByQuota(
|
||||||
candidates: OrderedStepItem[],
|
candidates: OrderedStepItem[],
|
||||||
uwf: UwfStore,
|
uwf: UwfStore,
|
||||||
|
workflow: WorkflowPayload,
|
||||||
quota: number,
|
quota: number,
|
||||||
|
startSectionLength: number,
|
||||||
): { selected: OrderedStepItem[]; skippedCount: number } {
|
): { selected: OrderedStepItem[]; skippedCount: number } {
|
||||||
const selected: OrderedStepItem[] = [];
|
const selected: OrderedStepItem[] = [];
|
||||||
let totalChars = 0;
|
|
||||||
|
// Start with start section length
|
||||||
|
let totalChars = startSectionLength;
|
||||||
|
|
||||||
for (let i = candidates.length - 1; i >= 0; i--) {
|
for (let i = candidates.length - 1; i >= 0; i--) {
|
||||||
const item = candidates[i];
|
const item = candidates[i];
|
||||||
if (item === undefined) continue;
|
if (item === undefined) continue;
|
||||||
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
|
|
||||||
const blockLen = formatCompactStep(i + 1, item, outputYaml).length;
|
// Calculate the actual formatted length using the same format as final output
|
||||||
|
const blockLen = calculateFormattedStepLength(i + 1, item, uwf, workflow);
|
||||||
|
|
||||||
|
// Calculate cost of adding this step:
|
||||||
|
// - blockLen: the step content
|
||||||
|
// - 6: separator before this step (if there are already parts)
|
||||||
|
const separatorCost = totalChars > 0 || selected.length > 0 ? 6 : 0;
|
||||||
|
const addCost = blockLen + separatorCost;
|
||||||
|
|
||||||
|
// Check quota BEFORE adding - but always include at least one step
|
||||||
|
if (totalChars + addCost > quota && selected.length > 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
selected.unshift(item);
|
selected.unshift(item);
|
||||||
totalChars += blockLen;
|
totalChars += addCost;
|
||||||
if (totalChars > quota) break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { selected, skippedCount: candidates.length - selected.length };
|
return { selected, skippedCount: candidates.length - selected.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,14 +579,14 @@ function formatStepPrompt(
|
|||||||
): string {
|
): string {
|
||||||
if (!roleDef || shownPromptRoles.has(role)) return "";
|
if (!roleDef || shownPromptRoles.has(role)) return "";
|
||||||
shownPromptRoles.add(role);
|
shownPromptRoles.add(role);
|
||||||
return ["", "", "### Prompt", "", roleDef.goal].join("\n");
|
return ["", "", "<prompt>", roleDef.goal, "</prompt>"].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatStepContent(uwf: UwfStore, item: OrderedStepItem): string {
|
function formatStepContent(uwf: UwfStore, item: OrderedStepItem): string {
|
||||||
if (!item.payload.detail) return "";
|
if (!item.payload.detail) return "";
|
||||||
const content = extractLastAssistantContent(uwf, item.payload.detail);
|
const content = extractLastAssistantContent(uwf, item.payload.detail);
|
||||||
if (content === null) return "";
|
if (content === null) return "";
|
||||||
return ["", "", "### Content", "", content].join("\n");
|
return ["", "", "<output>", content, "</output>"].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatStartSection(options: {
|
function formatStartSection(options: {
|
||||||
@@ -701,11 +624,21 @@ function formatThreadReadMarkdown(options: {
|
|||||||
const { ordered, uwf, workflow, quota, before } = options;
|
const { ordered, uwf, workflow, quota, before } = options;
|
||||||
|
|
||||||
const candidates = before !== null ? sliceBeforeHash(ordered, before, options.threadId) : ordered;
|
const candidates = before !== null ? sliceBeforeHash(ordered, before, options.threadId) : ordered;
|
||||||
const { selected, skippedCount } = selectByQuota(candidates, uwf, quota);
|
|
||||||
|
// Calculate start section length for quota accounting
|
||||||
|
const startSection = formatStartSection(options);
|
||||||
|
const startSectionLength = startSection !== "" ? startSection.length : 0;
|
||||||
|
|
||||||
|
const { selected, skippedCount } = selectByQuota(
|
||||||
|
candidates,
|
||||||
|
uwf,
|
||||||
|
workflow,
|
||||||
|
quota,
|
||||||
|
startSectionLength,
|
||||||
|
);
|
||||||
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
const startSection = formatStartSection(options);
|
|
||||||
if (startSection !== "") parts.push(startSection);
|
if (startSection !== "") parts.push(startSection);
|
||||||
|
|
||||||
if (skippedCount > 0 && selected.length > 0) {
|
if (skippedCount > 0 && selected.length > 0) {
|
||||||
@@ -745,6 +678,7 @@ function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorConte
|
|||||||
detail: step.detail,
|
detail: step.detail,
|
||||||
agent: step.agent,
|
agent: step.agent,
|
||||||
edgePrompt: step.edgePrompt ?? "",
|
edgePrompt: step.edgePrompt ?? "",
|
||||||
|
content: null, // Moderator doesn't need content
|
||||||
}));
|
}));
|
||||||
return { start: chain.start, steps };
|
return { start: chain.start, steps };
|
||||||
}
|
}
|
||||||
@@ -804,13 +738,11 @@ function spawnAgent(
|
|||||||
role: string,
|
role: string,
|
||||||
edgePrompt: string,
|
edgePrompt: string,
|
||||||
): CasRef {
|
): CasRef {
|
||||||
const argv = [...agent.args, threadId, role];
|
const argv = [...agent.args, "--thread", threadId, "--role", role, "--prompt", edgePrompt];
|
||||||
const env = { ...process.env, UWF_EDGE_PROMPT: edgePrompt };
|
|
||||||
let stdout: string;
|
let stdout: string;
|
||||||
try {
|
try {
|
||||||
stdout = execFileSync(agent.command, argv, {
|
stdout = execFileSync(agent.command, argv, {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
env,
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
maxBuffer: 50 * 1024 * 1024, // 50 MB — stream-json output can be large
|
maxBuffer: 50 * 1024 * 1024, // 50 MB — stream-json output can be large
|
||||||
});
|
});
|
||||||
@@ -850,31 +782,65 @@ async function archiveThread(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cmdThreadStep(
|
export async function cmdThreadExec(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
threadId: ThreadId,
|
threadId: ThreadId,
|
||||||
agentOverride: string | null,
|
agentOverride: string | null,
|
||||||
count: number,
|
count: number,
|
||||||
|
background: boolean,
|
||||||
|
backgroundWorker: boolean,
|
||||||
): Promise<StepOutput[]> {
|
): Promise<StepOutput[]> {
|
||||||
if (count < 1 || !Number.isInteger(count)) {
|
if (count < 1 || !Number.isInteger(count)) {
|
||||||
fail(`--count must be a positive integer, got: ${count}`);
|
fail(`--count must be a positive integer, got: ${count}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if thread is already running in background (unless we ARE the background worker)
|
||||||
|
if (!backgroundWorker) {
|
||||||
|
const runningMarker = await isThreadRunning(storageRoot, threadId);
|
||||||
|
if (runningMarker !== null) {
|
||||||
|
fail(`thread already executing in background (PID: ${runningMarker.pid})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const workflowHash = await resolveActiveThreadWorkflowHash(storageRoot, threadId);
|
const workflowHash = await resolveActiveThreadWorkflowHash(storageRoot, threadId);
|
||||||
const plog = createProcessLogger({
|
const plog = createProcessLogger({
|
||||||
storageRoot,
|
storageRoot,
|
||||||
context: { thread: threadId, workflow: workflowHash },
|
context: { thread: threadId, workflow: workflowHash },
|
||||||
});
|
});
|
||||||
|
|
||||||
const results: StepOutput[] = [];
|
if (background && !backgroundWorker) {
|
||||||
for (let i = 0; i < count; i++) {
|
// Spawn background process
|
||||||
const result = await cmdThreadStepOnce(storageRoot, threadId, agentOverride, plog);
|
return cmdThreadStepBackground(storageRoot, threadId, agentOverride, count, plog, workflowHash);
|
||||||
results.push(result);
|
}
|
||||||
if (result.done) {
|
|
||||||
break;
|
// If we're the background worker, create marker before execution
|
||||||
|
let markerCreated = false;
|
||||||
|
if (backgroundWorker) {
|
||||||
|
await createMarker(storageRoot, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow: workflowHash,
|
||||||
|
pid: process.pid,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
});
|
||||||
|
markerCreated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results: StepOutput[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const result = await cmdThreadStepOnce(storageRoot, threadId, agentOverride, plog);
|
||||||
|
results.push(result);
|
||||||
|
if (result.done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
} finally {
|
||||||
|
// Cleanup marker if we created one
|
||||||
|
if (markerCreated) {
|
||||||
|
await deleteMarker(storageRoot, threadId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveActiveThreadWorkflowHash(
|
async function resolveActiveThreadWorkflowHash(
|
||||||
@@ -891,6 +857,57 @@ async function resolveActiveThreadWorkflowHash(
|
|||||||
return chain.start.workflow;
|
return chain.start.workflow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function cmdThreadStepBackground(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: ThreadId,
|
||||||
|
agentOverride: string | null,
|
||||||
|
count: number,
|
||||||
|
plog: ProcessLogger,
|
||||||
|
workflowHash: CasRef,
|
||||||
|
): Promise<StepOutput[]> {
|
||||||
|
// Get current head to return to caller
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
const headHash = index[threadId];
|
||||||
|
if (headHash === undefined) {
|
||||||
|
failStep(plog, `thread not active: ${threadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn detached background process
|
||||||
|
const scriptPath = process.argv[1];
|
||||||
|
if (scriptPath === undefined) {
|
||||||
|
failStep(plog, "unable to determine script path for background execution");
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = ["thread", "exec", threadId, "--count", String(count)];
|
||||||
|
|
||||||
|
if (agentOverride !== null) {
|
||||||
|
args.push("--agent", agentOverride);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal flag to signal the background worker to create/cleanup markers
|
||||||
|
args.push("--_background-worker");
|
||||||
|
|
||||||
|
plog.log(PL_BACKGROUND_START, `spawning background process count=${count}`, null);
|
||||||
|
|
||||||
|
const child = spawn(scriptPath, args, {
|
||||||
|
detached: true,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
|
||||||
|
child.unref();
|
||||||
|
|
||||||
|
// Return immediately with current state and background flag
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
workflow: workflowHash,
|
||||||
|
thread: threadId,
|
||||||
|
head: headHash,
|
||||||
|
done: false,
|
||||||
|
background: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
async function cmdThreadStepOnce(
|
async function cmdThreadStepOnce(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
threadId: ThreadId,
|
threadId: ThreadId,
|
||||||
@@ -928,6 +945,7 @@ async function cmdThreadStepOnce(
|
|||||||
thread: threadId,
|
thread: threadId,
|
||||||
head: headHash,
|
head: headHash,
|
||||||
done: true,
|
done: true,
|
||||||
|
background: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -975,6 +993,7 @@ async function cmdThreadStepOnce(
|
|||||||
thread: threadId,
|
thread: threadId,
|
||||||
head: newHead,
|
head: newHead,
|
||||||
done,
|
done,
|
||||||
|
background: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -991,47 +1010,6 @@ async function resolveHeadHash(storageRoot: string, threadId: ThreadId): Promise
|
|||||||
fail(`thread not found: ${threadId}`);
|
fail(`thread not found: ${threadId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cmdThreadSteps(
|
|
||||||
storageRoot: string,
|
|
||||||
threadId: ThreadId,
|
|
||||||
): Promise<ThreadStepsOutput> {
|
|
||||||
const headHash = await resolveHeadHash(storageRoot, threadId);
|
|
||||||
const uwf = await createUwfStore(storageRoot);
|
|
||||||
const chain = walkChain(uwf, headHash);
|
|
||||||
|
|
||||||
const startNode = uwf.store.get(chain.startHash);
|
|
||||||
if (startNode === null) {
|
|
||||||
fail(`StartNode not found: ${chain.startHash}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const startEntry: StartEntry = {
|
|
||||||
hash: chain.startHash,
|
|
||||||
workflow: chain.start.workflow,
|
|
||||||
prompt: chain.start.prompt,
|
|
||||||
timestamp: startNode.timestamp,
|
|
||||||
};
|
|
||||||
|
|
||||||
const stepEntries: StepEntry[] = [];
|
|
||||||
const ordered = collectOrderedSteps(uwf, headHash, chain);
|
|
||||||
|
|
||||||
for (const item of ordered) {
|
|
||||||
stepEntries.push({
|
|
||||||
hash: item.hash,
|
|
||||||
role: item.payload.role,
|
|
||||||
output: expandOutput(uwf, item.payload.output),
|
|
||||||
detail: item.payload.detail,
|
|
||||||
agent: item.payload.agent,
|
|
||||||
timestamp: item.timestamp,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
thread: threadId,
|
|
||||||
workflow: chain.start.workflow,
|
|
||||||
steps: [startEntry, ...stepEntries],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cmdThreadRead(
|
export async function cmdThreadRead(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
threadId: ThreadId,
|
threadId: ThreadId,
|
||||||
@@ -1059,58 +1037,67 @@ export async function cmdThreadRead(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cmdThreadFork(
|
export type StopOutput = {
|
||||||
storageRoot: string,
|
thread: ThreadId;
|
||||||
stepHash: CasRef,
|
stopped: boolean;
|
||||||
): Promise<ThreadForkOutput> {
|
};
|
||||||
const uwf = await createUwfStore(storageRoot);
|
|
||||||
const node = uwf.store.get(stepHash);
|
|
||||||
if (node === null) {
|
|
||||||
fail(`CAS node not found: ${stepHash}`);
|
|
||||||
}
|
|
||||||
if (node.type !== uwf.schemas.startNode && node.type !== uwf.schemas.stepNode) {
|
|
||||||
fail(`node ${stepHash} is not a StartNode or StepNode`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newThreadId = generateUlid(Date.now()) as ThreadId;
|
export type CancelOutput = {
|
||||||
const index = await loadThreadsIndex(storageRoot);
|
thread: ThreadId;
|
||||||
index[newThreadId] = stepHash;
|
cancelled: boolean;
|
||||||
await saveThreadsIndex(storageRoot, index);
|
};
|
||||||
|
|
||||||
return {
|
/**
|
||||||
thread: newThreadId,
|
* Stop background execution of a thread (but keep thread active)
|
||||||
forkedFrom: {
|
*/
|
||||||
step: stepHash,
|
export async function cmdThreadStop(storageRoot: string, threadId: ThreadId): Promise<StopOutput> {
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cmdThreadStepDetails(
|
|
||||||
storageRoot: string,
|
|
||||||
stepHash: CasRef,
|
|
||||||
): Promise<unknown> {
|
|
||||||
const uwf = await createUwfStore(storageRoot);
|
|
||||||
const node = uwf.store.get(stepHash);
|
|
||||||
if (node === null) {
|
|
||||||
fail(`CAS node not found: ${stepHash}`);
|
|
||||||
}
|
|
||||||
if (node.type !== uwf.schemas.stepNode) {
|
|
||||||
fail(`node ${stepHash} is not a StepNode`);
|
|
||||||
}
|
|
||||||
const payload = node.payload as StepNodePayload;
|
|
||||||
if (!payload.detail) {
|
|
||||||
fail(`step ${stepHash} has no detail`);
|
|
||||||
}
|
|
||||||
return expandDeep(uwf.store, payload.detail);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cmdThreadKill(storageRoot: string, threadId: ThreadId): Promise<KillOutput> {
|
|
||||||
const index = await loadThreadsIndex(storageRoot);
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
const head = index[threadId];
|
const head = index[threadId];
|
||||||
if (head === undefined) {
|
if (head === undefined) {
|
||||||
fail(`thread not active: ${threadId}`);
|
fail(`thread not active: ${threadId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if thread is running in background and terminate it
|
||||||
|
const runningMarker = await isThreadRunning(storageRoot, threadId);
|
||||||
|
if (runningMarker === null) {
|
||||||
|
process.stderr.write(`Warning: thread ${threadId} is not currently running\n`);
|
||||||
|
return { thread: threadId, stopped: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.kill(runningMarker.pid, "SIGTERM");
|
||||||
|
} catch {
|
||||||
|
// Process may have already exited, ignore error
|
||||||
|
}
|
||||||
|
await deleteMarker(storageRoot, threadId);
|
||||||
|
|
||||||
|
return { thread: threadId, stopped: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel a thread (stop execution + move to history)
|
||||||
|
*/
|
||||||
|
export async function cmdThreadCancel(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: ThreadId,
|
||||||
|
): Promise<CancelOutput> {
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
const head = index[threadId];
|
||||||
|
if (head === undefined) {
|
||||||
|
fail(`thread not active: ${threadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if thread is running in background and terminate it
|
||||||
|
const runningMarker = await isThreadRunning(storageRoot, threadId);
|
||||||
|
if (runningMarker !== null) {
|
||||||
|
try {
|
||||||
|
process.kill(runningMarker.pid, "SIGTERM");
|
||||||
|
} catch {
|
||||||
|
// Process may have already exited, ignore error
|
||||||
|
}
|
||||||
|
await deleteMarker(storageRoot, threadId);
|
||||||
|
}
|
||||||
|
|
||||||
const uwf = await createUwfStore(storageRoot);
|
const uwf = await createUwfStore(storageRoot);
|
||||||
const workflow = resolveWorkflowFromHead(uwf, head);
|
const workflow = resolveWorkflowFromHead(uwf, head);
|
||||||
if (workflow === null) {
|
if (workflow === null) {
|
||||||
@@ -1128,5 +1115,5 @@ export async function cmdThreadKill(storageRoot: string, threadId: ThreadId): Pr
|
|||||||
};
|
};
|
||||||
await appendThreadHistory(storageRoot, historyEntry);
|
await appendThreadHistory(storageRoot, historyEntry);
|
||||||
|
|
||||||
return { thread: threadId, archived: true };
|
return { thread: threadId, cancelled: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export type WorkflowListEntry = {
|
|||||||
origin: WorkflowOrigin;
|
origin: WorkflowOrigin;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkflowPutOutput = {
|
export type WorkflowAddOutput = {
|
||||||
name: string;
|
name: string;
|
||||||
hash: CasRef;
|
hash: CasRef;
|
||||||
};
|
};
|
||||||
@@ -111,10 +111,10 @@ export async function materializeWorkflowPayload(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cmdWorkflowPut(
|
export async function cmdWorkflowAdd(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
): Promise<WorkflowPutOutput> {
|
): Promise<WorkflowAddOutput> {
|
||||||
let text: string;
|
let text: string;
|
||||||
try {
|
try {
|
||||||
text = await readFile(filePath, "utf8");
|
text = await readFile(filePath, "utf8");
|
||||||
|
|||||||
@@ -19,7 +19,14 @@ mock.module("../src/tools/index.js", () => ({
|
|||||||
getBuiltinTools: () => [],
|
getBuiltinTools: () => [],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { executeTurnTools, runBuiltinLoop, shouldNudge } from "../src/loop.js";
|
import {
|
||||||
|
executeTurnTools,
|
||||||
|
extractFinalText,
|
||||||
|
runBuiltinLoop,
|
||||||
|
shouldInjectDeadlineWarning,
|
||||||
|
shouldNudge,
|
||||||
|
shouldProcessToolCalls,
|
||||||
|
} from "../src/loop.js";
|
||||||
|
|
||||||
const fakeProvider = {} as any;
|
const fakeProvider = {} as any;
|
||||||
const fakeToolCtx = {} as any;
|
const fakeToolCtx = {} as any;
|
||||||
@@ -154,3 +161,96 @@ describe("runBuiltinLoop integration", () => {
|
|||||||
expect(original.length).toBe(1);
|
expect(original.length).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("shouldInjectDeadlineWarning", () => {
|
||||||
|
test("5.1 returns true when turn count reaches warning threshold and not yet warned", () => {
|
||||||
|
expect(shouldInjectDeadlineWarning(7, 10, false, false)).toBe(true);
|
||||||
|
});
|
||||||
|
test("5.2 returns false when already warned", () => {
|
||||||
|
expect(shouldInjectDeadlineWarning(7, 10, true, false)).toBe(false);
|
||||||
|
});
|
||||||
|
test("5.3 returns false when noTools is true", () => {
|
||||||
|
expect(shouldInjectDeadlineWarning(7, 10, false, true)).toBe(false);
|
||||||
|
});
|
||||||
|
test("5.4 returns false when turns remaining > DEADLINE_WARNING_TURNS", () => {
|
||||||
|
expect(shouldInjectDeadlineWarning(5, 10, false, false)).toBe(false);
|
||||||
|
});
|
||||||
|
test("5.5 returns true when exactly at warning threshold", () => {
|
||||||
|
expect(shouldInjectDeadlineWarning(7, 10, false, false)).toBe(true);
|
||||||
|
});
|
||||||
|
test("5.6 returns false when turns remaining is 0", () => {
|
||||||
|
expect(shouldInjectDeadlineWarning(10, 10, false, false)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldProcessToolCalls", () => {
|
||||||
|
test("6.1 returns true when toolCalls present and noTools=false", () => {
|
||||||
|
expect(shouldProcessToolCalls([{ id: "x", name: "read", arguments: "{}" }], false)).toBe(true);
|
||||||
|
});
|
||||||
|
test("6.2 returns false when toolCalls is null", () => {
|
||||||
|
expect(shouldProcessToolCalls(null, false)).toBe(false);
|
||||||
|
});
|
||||||
|
test("6.3 returns false when toolCalls is empty array", () => {
|
||||||
|
expect(shouldProcessToolCalls([], false)).toBe(false);
|
||||||
|
});
|
||||||
|
test("6.4 returns false when noTools=true", () => {
|
||||||
|
expect(shouldProcessToolCalls([{ id: "x", name: "read", arguments: "{}" }], true)).toBe(false);
|
||||||
|
});
|
||||||
|
test("6.5 returns true when multiple tool calls present", () => {
|
||||||
|
expect(
|
||||||
|
shouldProcessToolCalls(
|
||||||
|
[
|
||||||
|
{ id: "x1", name: "read", arguments: "{}" },
|
||||||
|
{ id: "x2", name: "write", arguments: "{}" },
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractFinalText", () => {
|
||||||
|
test("7.1 returns last assistant message content", () => {
|
||||||
|
const messages = [
|
||||||
|
{ role: "system" as const, content: "sys", tool_calls: null },
|
||||||
|
{ role: "assistant" as const, content: "first", tool_calls: null },
|
||||||
|
{ role: "assistant" as const, content: "last", tool_calls: null },
|
||||||
|
];
|
||||||
|
expect(extractFinalText(messages)).toBe("last");
|
||||||
|
});
|
||||||
|
test("7.2 returns empty string when no assistant messages", () => {
|
||||||
|
expect(extractFinalText([{ role: "system" as const, content: "sys", tool_calls: null }])).toBe(
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("7.3 skips assistant messages with null content", () => {
|
||||||
|
const messages = [
|
||||||
|
{ role: "assistant" as const, content: "first", tool_calls: null },
|
||||||
|
{
|
||||||
|
role: "assistant" as const,
|
||||||
|
content: null,
|
||||||
|
tool_calls: [{ id: "x", name: "t", arguments: "{}" }],
|
||||||
|
},
|
||||||
|
{ role: "assistant" as const, content: "second", tool_calls: null },
|
||||||
|
];
|
||||||
|
expect(extractFinalText(messages)).toBe("second");
|
||||||
|
});
|
||||||
|
test("7.4 skips assistant messages with empty content", () => {
|
||||||
|
const messages = [
|
||||||
|
{ role: "assistant" as const, content: "first", tool_calls: null },
|
||||||
|
{ role: "assistant" as const, content: "", tool_calls: null },
|
||||||
|
{ role: "user" as const, content: "nudge", tool_calls: null },
|
||||||
|
];
|
||||||
|
expect(extractFinalText(messages)).toBe("first");
|
||||||
|
});
|
||||||
|
test("7.5 handles empty messages array", () => {
|
||||||
|
expect(extractFinalText([])).toBe("");
|
||||||
|
});
|
||||||
|
test("7.6 handles messages with only user and system roles", () => {
|
||||||
|
const messages = [
|
||||||
|
{ role: "system" as const, content: "sys", tool_calls: null },
|
||||||
|
{ role: "user" as const, content: "query", tool_calls: null },
|
||||||
|
];
|
||||||
|
expect(extractFinalText(messages)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import type { ResolvedLlmProvider } from "@uncaged/workflow-agent-kit";
|
import type { ResolvedLlmProvider } from "@uncaged/workflow-agent-kit";
|
||||||
import { createLogger } from "@uncaged/workflow-util";
|
import { createLogger } from "@uncaged/workflow-util";
|
||||||
|
|
||||||
import { type ChatMessage, chatCompletionWithTools, type LlmToolCall } from "./llm/index.js";
|
import {
|
||||||
|
type ChatMessage,
|
||||||
|
chatCompletionWithTools,
|
||||||
|
type LlmToolCall,
|
||||||
|
type OpenAiToolDefinition,
|
||||||
|
} from "./llm/index.js";
|
||||||
import { appendSessionTurn } from "./session.js";
|
import { appendSessionTurn } from "./session.js";
|
||||||
import {
|
import {
|
||||||
builtinToolsToOpenAi,
|
builtinToolsToOpenAi,
|
||||||
@@ -80,10 +85,184 @@ export type ShouldNudgeOptions = {
|
|||||||
const MAX_NUDGES = 3;
|
const MAX_NUDGES = 3;
|
||||||
const DEADLINE_WARNING_TURNS = 3;
|
const DEADLINE_WARNING_TURNS = 3;
|
||||||
|
|
||||||
|
export function shouldInjectDeadlineWarning(
|
||||||
|
turn: number,
|
||||||
|
maxTurns: number,
|
||||||
|
alreadyWarned: boolean,
|
||||||
|
noTools: boolean,
|
||||||
|
): boolean {
|
||||||
|
const turnsRemaining = maxTurns - turn;
|
||||||
|
return (
|
||||||
|
!noTools && !alreadyWarned && turnsRemaining > 0 && turnsRemaining <= DEADLINE_WARNING_TURNS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldProcessToolCalls(toolCalls: LlmToolCall[] | null, noTools: boolean): boolean {
|
||||||
|
return !noTools && toolCalls !== null && toolCalls.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractFinalText(messages: ChatMessage[]): string {
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const msg = messages[i];
|
||||||
|
if (
|
||||||
|
msg !== undefined &&
|
||||||
|
msg.role === "assistant" &&
|
||||||
|
msg.content !== null &&
|
||||||
|
msg.content.trim() !== ""
|
||||||
|
) {
|
||||||
|
return msg.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectDeadlineWarning(messages: ChatMessage[], turnsRemaining: number): void {
|
||||||
|
log("4NRXW6KT", `${turnsRemaining} turns remaining, injecting deadline warning`);
|
||||||
|
messages.push({
|
||||||
|
role: "user",
|
||||||
|
content:
|
||||||
|
`⚠️ You have ${turnsRemaining} turns remaining. ` +
|
||||||
|
"Wrap up your work and output the YAML frontmatter starting with `---`. " +
|
||||||
|
"If you cannot finish in time, output frontmatter with `status: failed` and describe what remains.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type HandleTextOnlyTurnResult = {
|
||||||
|
shouldBreak: boolean;
|
||||||
|
finalText: string;
|
||||||
|
turnCount: number;
|
||||||
|
nudgeCount: number;
|
||||||
|
turnAdjustment: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleTextOnlyTurn(
|
||||||
|
text: string,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
storageRoot: string,
|
||||||
|
sessionId: string,
|
||||||
|
noTools: boolean,
|
||||||
|
turn: number,
|
||||||
|
maxTurns: number,
|
||||||
|
currentNudgeCount: number,
|
||||||
|
): Promise<HandleTextOnlyTurnResult> {
|
||||||
|
await appendTurn(storageRoot, sessionId, {
|
||||||
|
role: "assistant",
|
||||||
|
content: text,
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const turnCount = 1;
|
||||||
|
let nudgeCount = currentNudgeCount;
|
||||||
|
let turnAdjustment = 0;
|
||||||
|
|
||||||
|
if (shouldNudge({ noTools, text, turn, maxTurns })) {
|
||||||
|
nudgeCount += 1;
|
||||||
|
log("7FXQM2KN", `text-only turn without frontmatter, nudge ${nudgeCount}/${MAX_NUDGES}`);
|
||||||
|
const nudge =
|
||||||
|
"You stopped calling tools but your response does not start with the required `---` YAML frontmatter. " +
|
||||||
|
"Either continue using tools to complete your work, or output your final response starting with `---`.";
|
||||||
|
messages.push({ role: "user", content: nudge });
|
||||||
|
// Nudge doesn't consume turn budget (up to MAX_NUDGES)
|
||||||
|
if (nudgeCount <= MAX_NUDGES) {
|
||||||
|
turnAdjustment = -1;
|
||||||
|
}
|
||||||
|
return { shouldBreak: false, finalText: "", turnCount, nudgeCount, turnAdjustment };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { shouldBreak: true, finalText: text, turnCount, nudgeCount, turnAdjustment };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToolCallTurn(
|
||||||
|
content: string,
|
||||||
|
toolCalls: LlmToolCall[],
|
||||||
|
messages: ChatMessage[],
|
||||||
|
storageRoot: string,
|
||||||
|
sessionId: string,
|
||||||
|
toolCtx: ToolContext,
|
||||||
|
): Promise<number> {
|
||||||
|
await appendTurn(storageRoot, sessionId, {
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
toolCalls: mapToolCallsForPayload(toolCalls),
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
let turnCount = 1;
|
||||||
|
|
||||||
|
// Execute tools
|
||||||
|
turnCount += await executeTurnTools(toolCalls, toolCtx, messages, storageRoot, sessionId);
|
||||||
|
|
||||||
|
return turnCount;
|
||||||
|
}
|
||||||
|
|
||||||
export function shouldNudge({ noTools, text, turn, maxTurns }: ShouldNudgeOptions): boolean {
|
export function shouldNudge({ noTools, text, turn, maxTurns }: ShouldNudgeOptions): boolean {
|
||||||
return !noTools && !text.trimStart().startsWith("---") && turn < maxTurns - 1;
|
return !noTools && !text.trimStart().startsWith("---") && turn < maxTurns - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProcessLoopIterationResult = {
|
||||||
|
shouldBreak: boolean;
|
||||||
|
finalText: string;
|
||||||
|
turnCount: number;
|
||||||
|
nudgeCount: number;
|
||||||
|
turnAdjustment: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function processLoopIteration(
|
||||||
|
options: RunBuiltinLoopOptions,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
openAiTools: OpenAiToolDefinition[],
|
||||||
|
turn: number,
|
||||||
|
nudgeCount: number,
|
||||||
|
): Promise<ProcessLoopIterationResult> {
|
||||||
|
const response = await chatCompletionWithTools(
|
||||||
|
options.provider,
|
||||||
|
messages,
|
||||||
|
openAiTools.length > 0 ? openAiTools : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// When noTools is set, ignore any tool_calls the LLM might still return
|
||||||
|
const effectiveToolCalls = options.noTools ? null : (response.toolCalls ?? null);
|
||||||
|
|
||||||
|
const assistantMessage: ChatMessage = {
|
||||||
|
role: "assistant",
|
||||||
|
content: response.content,
|
||||||
|
tool_calls: effectiveToolCalls,
|
||||||
|
};
|
||||||
|
messages.push(assistantMessage);
|
||||||
|
|
||||||
|
if (!shouldProcessToolCalls(effectiveToolCalls, options.noTools)) {
|
||||||
|
const text = response.content ?? "";
|
||||||
|
const result = await handleTextOnlyTurn(
|
||||||
|
text,
|
||||||
|
messages,
|
||||||
|
options.storageRoot,
|
||||||
|
options.sessionId,
|
||||||
|
options.noTools,
|
||||||
|
turn,
|
||||||
|
options.maxTurns,
|
||||||
|
nudgeCount,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// At this point, effectiveToolCalls is guaranteed to be non-null and non-empty
|
||||||
|
const turnCount = await handleToolCallTurn(
|
||||||
|
response.content ?? "",
|
||||||
|
effectiveToolCalls as LlmToolCall[],
|
||||||
|
messages,
|
||||||
|
options.storageRoot,
|
||||||
|
options.sessionId,
|
||||||
|
options.toolCtx,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldBreak: false,
|
||||||
|
finalText: "",
|
||||||
|
turnCount,
|
||||||
|
nudgeCount,
|
||||||
|
turnAdjustment: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Agent run loop: LLM ↔ tools until no tool_calls or maxTurns. */
|
/** Agent run loop: LLM ↔ tools until no tool_calls or maxTurns. */
|
||||||
export async function runBuiltinLoop(
|
export async function runBuiltinLoop(
|
||||||
options: RunBuiltinLoopOptions,
|
options: RunBuiltinLoopOptions,
|
||||||
@@ -99,95 +278,25 @@ export async function runBuiltinLoop(
|
|||||||
log("8K2M4N7P", `builtin loop turn ${turn + 1}/${options.maxTurns}`);
|
log("8K2M4N7P", `builtin loop turn ${turn + 1}/${options.maxTurns}`);
|
||||||
|
|
||||||
// Warn agent when approaching turn limit
|
// Warn agent when approaching turn limit
|
||||||
const turnsRemaining = options.maxTurns - turn;
|
if (shouldInjectDeadlineWarning(turn, options.maxTurns, deadlineWarned, options.noTools)) {
|
||||||
if (!options.noTools && !deadlineWarned && turnsRemaining <= DEADLINE_WARNING_TURNS) {
|
|
||||||
deadlineWarned = true;
|
deadlineWarned = true;
|
||||||
log("4NRXW6KT", `${turnsRemaining} turns remaining, injecting deadline warning`);
|
const turnsRemaining = options.maxTurns - turn;
|
||||||
messages.push({
|
injectDeadlineWarning(messages, turnsRemaining);
|
||||||
role: "user",
|
|
||||||
content:
|
|
||||||
`⚠️ You have ${turnsRemaining} turns remaining. ` +
|
|
||||||
"Wrap up your work and output the YAML frontmatter starting with `---`. " +
|
|
||||||
"If you cannot finish in time, output frontmatter with `status: failed` and describe what remains.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await chatCompletionWithTools(
|
const result = await processLoopIteration(options, messages, openAiTools, turn, nudgeCount);
|
||||||
options.provider,
|
turnCount += result.turnCount;
|
||||||
messages,
|
nudgeCount = result.nudgeCount;
|
||||||
openAiTools.length > 0 ? openAiTools : null,
|
turn += result.turnAdjustment;
|
||||||
);
|
|
||||||
|
|
||||||
// When noTools is set, ignore any tool_calls the LLM might still return
|
if (result.shouldBreak) {
|
||||||
const effectiveToolCalls = options.noTools ? null : (response.toolCalls ?? null);
|
finalText = result.finalText;
|
||||||
|
|
||||||
const assistantMessage: ChatMessage = {
|
|
||||||
role: "assistant",
|
|
||||||
content: response.content,
|
|
||||||
tool_calls: effectiveToolCalls,
|
|
||||||
};
|
|
||||||
messages.push(assistantMessage);
|
|
||||||
|
|
||||||
if (effectiveToolCalls === null || effectiveToolCalls.length === 0) {
|
|
||||||
const text = response.content ?? "";
|
|
||||||
await appendTurn(options.storageRoot, options.sessionId, {
|
|
||||||
role: "assistant",
|
|
||||||
content: text,
|
|
||||||
toolCalls: null,
|
|
||||||
reasoning: null,
|
|
||||||
});
|
|
||||||
turnCount += 1;
|
|
||||||
|
|
||||||
if (shouldNudge({ noTools: options.noTools, text, turn, maxTurns: options.maxTurns })) {
|
|
||||||
nudgeCount += 1;
|
|
||||||
log("7FXQM2KN", `text-only turn without frontmatter, nudge ${nudgeCount}/${MAX_NUDGES}`);
|
|
||||||
const nudge =
|
|
||||||
"You stopped calling tools but your response does not start with the required `---` YAML frontmatter. " +
|
|
||||||
"Either continue using tools to complete your work, or output your final response starting with `---`.";
|
|
||||||
messages.push({ role: "user", content: nudge });
|
|
||||||
// Nudge doesn't consume turn budget (up to MAX_NUDGES)
|
|
||||||
if (nudgeCount <= MAX_NUDGES) {
|
|
||||||
turn -= 1;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
finalText = text;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assistant turn with tool calls
|
|
||||||
await appendTurn(options.storageRoot, options.sessionId, {
|
|
||||||
role: "assistant",
|
|
||||||
content: response.content ?? "",
|
|
||||||
toolCalls: mapToolCallsForPayload(effectiveToolCalls),
|
|
||||||
reasoning: null,
|
|
||||||
});
|
|
||||||
turnCount += 1;
|
|
||||||
|
|
||||||
// Execute tools
|
|
||||||
turnCount += await executeTurnTools(
|
|
||||||
effectiveToolCalls,
|
|
||||||
options.toolCtx,
|
|
||||||
messages,
|
|
||||||
options.storageRoot,
|
|
||||||
options.sessionId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (finalText === "" && messages.length > 0) {
|
if (finalText === "") {
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
finalText = extractFinalText(messages);
|
||||||
const msg = messages[i];
|
|
||||||
if (
|
|
||||||
msg !== undefined &&
|
|
||||||
msg.role === "assistant" &&
|
|
||||||
msg.content !== null &&
|
|
||||||
msg.content.trim() !== ""
|
|
||||||
) {
|
|
||||||
finalText = msg.content;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { finalText, messages, turnCount };
|
return { finalText, messages, turnCount };
|
||||||
|
|||||||
@@ -154,6 +154,99 @@ describe("parseClaudeCodeStreamOutput", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseClaudeCodeStreamOutput — helper extraction", () => {
|
||||||
|
test("processSystemLine sets model from system message", () => {
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({ type: "system", model: "claude-opus-4" }),
|
||||||
|
JSON.stringify({
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
result: "ok",
|
||||||
|
session_id: "s1",
|
||||||
|
num_turns: 0,
|
||||||
|
total_cost_usd: 0,
|
||||||
|
duration_ms: 0,
|
||||||
|
stop_reason: "end_turn",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
expect(parsed!.model).toBe("claude-opus-4");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("processAssistantLine skips empty content", () => {
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({ type: "assistant", message: { role: "assistant", content: [] } }),
|
||||||
|
JSON.stringify({
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
result: "ok",
|
||||||
|
session_id: "s1",
|
||||||
|
num_turns: 0,
|
||||||
|
total_cost_usd: 0,
|
||||||
|
duration_ms: 0,
|
||||||
|
stop_reason: "end_turn",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
expect(parsed!.turns).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("processUserLine skips when no tool_result items", () => {
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({
|
||||||
|
type: "user",
|
||||||
|
message: { role: "user", content: [{ type: "text", text: "hi" }] },
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
result: "ok",
|
||||||
|
session_id: "s1",
|
||||||
|
num_turns: 0,
|
||||||
|
total_cost_usd: 0,
|
||||||
|
duration_ms: 0,
|
||||||
|
stop_reason: "end_turn",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
expect(parsed!.turns).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("turn indices are sequential across mixed assistant and user lines", () => {
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({
|
||||||
|
type: "assistant",
|
||||||
|
message: { role: "assistant", content: [{ type: "text", text: "A" }] },
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
type: "user",
|
||||||
|
message: { role: "user", content: [{ type: "tool_result", content: "R" }] },
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
type: "assistant",
|
||||||
|
message: { role: "assistant", content: [{ type: "text", text: "B" }] },
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
result: "ok",
|
||||||
|
session_id: "s1",
|
||||||
|
num_turns: 3,
|
||||||
|
total_cost_usd: 0,
|
||||||
|
duration_ms: 0,
|
||||||
|
stop_reason: "end_turn",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
expect(parsed!.turns).toHaveLength(3);
|
||||||
|
expect(parsed!.turns.map((t) => t.index)).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("storeClaudeCodeDetail", () => {
|
describe("storeClaudeCodeDetail", () => {
|
||||||
const baseParsed: ClaudeCodeParsedResult = {
|
const baseParsed: ClaudeCodeParsedResult = {
|
||||||
type: "result",
|
type: "result",
|
||||||
|
|||||||
@@ -22,7 +22,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/json-cas": "^0.4.0",
|
"@uncaged/json-cas": "^0.4.0",
|
||||||
"@uncaged/workflow-agent-kit": "workspace:^"
|
"@uncaged/workflow-agent-kit": "workspace:^",
|
||||||
|
"@uncaged/workflow-util": "workspace:^"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const log = createLogger({ sink: { kind: "stderr" } });
|
|||||||
|
|
||||||
const CLAUDE_COMMAND = "claude";
|
const CLAUDE_COMMAND = "claude";
|
||||||
const CLAUDE_MAX_TURNS = 90;
|
const CLAUDE_MAX_TURNS = 90;
|
||||||
const CLAUDE_MODEL = process.env["CLAUDE_MODEL"] ?? null;
|
const CLAUDE_MODEL = process.env.CLAUDE_MODEL ?? null;
|
||||||
|
|
||||||
function buildHistorySummary(steps: AgentContext["steps"]): string {
|
function buildHistorySummary(steps: AgentContext["steps"]): string {
|
||||||
if (steps.length === 0) {
|
if (steps.length === 0) {
|
||||||
@@ -146,13 +146,13 @@ async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
|
|||||||
|
|
||||||
// Try resuming a cached session for re-entry scenarios (e.g. reviewer reject → developer re-entry).
|
// Try resuming a cached session for re-entry scenarios (e.g. reviewer reject → developer re-entry).
|
||||||
if (!ctx.isFirstVisit) {
|
if (!ctx.isFirstVisit) {
|
||||||
const cachedSessionId = await getCachedSessionId(ctx.threadId, ctx.role);
|
const cachedSessionId = await getCachedSessionId("claude-code", ctx.threadId, ctx.role);
|
||||||
if (cachedSessionId !== null) {
|
if (cachedSessionId !== null) {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await spawnClaudeResume(cachedSessionId, fullPrompt);
|
const { stdout } = await spawnClaudeResume(cachedSessionId, fullPrompt);
|
||||||
const result = await processClaudeOutput(stdout, ctx.store);
|
const result = await processClaudeOutput(stdout, ctx.store);
|
||||||
if (result.sessionId !== undefined && result.sessionId !== "") {
|
if (result.sessionId !== undefined && result.sessionId !== "") {
|
||||||
await setCachedSessionId(ctx.threadId, ctx.role, result.sessionId);
|
await setCachedSessionId("claude-code", ctx.threadId, ctx.role, result.sessionId);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -169,7 +169,7 @@ async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
|
|||||||
const { stdout } = await spawnClaudeRun(fullPrompt);
|
const { stdout } = await spawnClaudeRun(fullPrompt);
|
||||||
const result = await processClaudeOutput(stdout, ctx.store);
|
const result = await processClaudeOutput(stdout, ctx.store);
|
||||||
if (result.sessionId !== undefined && result.sessionId !== "") {
|
if (result.sessionId !== undefined && result.sessionId !== "") {
|
||||||
await setCachedSessionId(ctx.threadId, ctx.role, result.sessionId);
|
await setCachedSessionId("claude-code", ctx.threadId, ctx.role, result.sessionId);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export const CLAUDE_CODE_DETAIL_SCHEMA: JSONSchema = {
|
|||||||
},
|
},
|
||||||
turns: {
|
turns: {
|
||||||
type: "array",
|
type: "array",
|
||||||
items: { type: "string" },
|
items: { type: "string", format: "cas_ref" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
|
|||||||
@@ -67,101 +67,105 @@ function extractToolResultContent(content: unknown[]): string {
|
|||||||
return results.join("\n");
|
return results.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
type ParseState = {
|
||||||
* Parse Claude Code stream-json (NDJSON) output.
|
turns: ClaudeCodeTurnPayload[];
|
||||||
* Each line is a JSON object with type: "system" | "assistant" | "user" | "result".
|
resultLine: Record<string, unknown> | null;
|
||||||
*/
|
model: string;
|
||||||
export function parseClaudeCodeStreamOutput(stdout: string): ClaudeCodeParsedResult | null {
|
turnIndex: number;
|
||||||
const lines = stdout.trim().split("\n");
|
};
|
||||||
const turns: ClaudeCodeTurnPayload[] = [];
|
|
||||||
let resultLine: Record<string, unknown> | null = null;
|
|
||||||
let model = "";
|
|
||||||
let turnIndex = 0;
|
|
||||||
|
|
||||||
for (const line of lines) {
|
function processSystemLine(parsed: Record<string, unknown>, state: ParseState): void {
|
||||||
let parsed: unknown;
|
if (typeof parsed.model === "string") {
|
||||||
try {
|
state.model = parsed.model;
|
||||||
parsed = JSON.parse(line);
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!isRecord(parsed)) continue;
|
|
||||||
|
|
||||||
const type = parsed.type;
|
|
||||||
|
|
||||||
if (type === "system" && typeof parsed.model === "string") {
|
|
||||||
model = parsed.model;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "assistant" && isRecord(parsed.message)) {
|
|
||||||
const msg = parsed.message;
|
|
||||||
const content = Array.isArray(msg.content) ? msg.content : [];
|
|
||||||
const textContent = extractTextContent(content as unknown[]);
|
|
||||||
const toolCalls = extractToolCalls(content as unknown[]);
|
|
||||||
|
|
||||||
// Only record turns that have actual content
|
|
||||||
if (textContent !== "" || toolCalls.length > 0) {
|
|
||||||
turns.push({
|
|
||||||
index: turnIndex++,
|
|
||||||
role: "assistant",
|
|
||||||
content: textContent,
|
|
||||||
toolCalls: toolCalls.length > 0 ? toolCalls : null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "user" && isRecord(parsed.message)) {
|
|
||||||
const msg = parsed.message;
|
|
||||||
const content = Array.isArray(msg.content) ? msg.content : [];
|
|
||||||
const resultContent = extractToolResultContent(content as unknown[]);
|
|
||||||
|
|
||||||
if (resultContent !== "") {
|
|
||||||
turns.push({
|
|
||||||
index: turnIndex++,
|
|
||||||
role: "tool_result",
|
|
||||||
content: resultContent,
|
|
||||||
toolCalls: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "result") {
|
|
||||||
resultLine = parsed;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (resultLine === null) return null;
|
function processAssistantLine(parsed: Record<string, unknown>, state: ParseState): void {
|
||||||
|
if (!isRecord(parsed.message)) return;
|
||||||
|
const content = Array.isArray(parsed.message.content) ? parsed.message.content : [];
|
||||||
|
const textContent = extractTextContent(content as unknown[]);
|
||||||
|
const toolCalls = extractToolCalls(content as unknown[]);
|
||||||
|
if (textContent !== "" || toolCalls.length > 0) {
|
||||||
|
state.turns.push({
|
||||||
|
index: state.turnIndex++,
|
||||||
|
role: "assistant",
|
||||||
|
content: textContent,
|
||||||
|
toolCalls: toolCalls.length > 0 ? toolCalls : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sessionId = resultLine.session_id;
|
function processUserLine(parsed: Record<string, unknown>, state: ParseState): void {
|
||||||
const result = resultLine.result;
|
if (!isRecord(parsed.message)) return;
|
||||||
const subtype = resultLine.subtype;
|
const content = Array.isArray(parsed.message.content) ? parsed.message.content : [];
|
||||||
|
const resultContent = extractToolResultContent(content as unknown[]);
|
||||||
|
if (resultContent !== "") {
|
||||||
|
state.turns.push({
|
||||||
|
index: state.turnIndex++,
|
||||||
|
role: "tool_result",
|
||||||
|
content: resultContent,
|
||||||
|
toolCalls: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function processLine(line: string, state: ParseState): void {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isRecord(parsed)) return;
|
||||||
|
const type = parsed.type;
|
||||||
|
if (type === "system") processSystemLine(parsed, state);
|
||||||
|
else if (type === "assistant") processAssistantLine(parsed, state);
|
||||||
|
else if (type === "user") processUserLine(parsed, state);
|
||||||
|
else if (type === "result") state.resultLine = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assembleResult(state: ParseState): ClaudeCodeParsedResult | null {
|
||||||
|
if (state.resultLine === null) return null;
|
||||||
|
const sessionId = state.resultLine.session_id;
|
||||||
|
const result = state.resultLine.result;
|
||||||
|
const subtype = state.resultLine.subtype;
|
||||||
if (typeof sessionId !== "string" || typeof result !== "string" || typeof subtype !== "string") {
|
if (typeof sessionId !== "string" || typeof result !== "string" || typeof subtype !== "string") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const usage = isRecord(state.resultLine.usage) ? state.resultLine.usage : {};
|
||||||
const usage = isRecord(resultLine.usage) ? resultLine.usage : {};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: safeString(resultLine.type, "result"),
|
type: safeString(state.resultLine.type, "result"),
|
||||||
subtype: subtype as ClaudeCodeParsedResult["subtype"],
|
subtype: subtype as ClaudeCodeParsedResult["subtype"],
|
||||||
result,
|
result,
|
||||||
sessionId,
|
sessionId,
|
||||||
numTurns: safeNumber(resultLine.num_turns),
|
numTurns: safeNumber(state.resultLine.num_turns),
|
||||||
totalCostUsd: safeNumber(resultLine.total_cost_usd),
|
totalCostUsd: safeNumber(state.resultLine.total_cost_usd),
|
||||||
durationMs: safeNumber(resultLine.duration_ms),
|
durationMs: safeNumber(state.resultLine.duration_ms),
|
||||||
model,
|
model: state.model,
|
||||||
stopReason: safeString(resultLine.stop_reason),
|
stopReason: safeString(state.resultLine.stop_reason),
|
||||||
usage: {
|
usage: {
|
||||||
inputTokens: safeNumber(usage.input_tokens),
|
inputTokens: safeNumber(usage.input_tokens),
|
||||||
outputTokens: safeNumber(usage.output_tokens),
|
outputTokens: safeNumber(usage.output_tokens),
|
||||||
cacheReadInputTokens: safeNumber(usage.cache_read_input_tokens),
|
cacheReadInputTokens: safeNumber(usage.cache_read_input_tokens),
|
||||||
cacheCreationInputTokens: safeNumber(usage.cache_creation_input_tokens),
|
cacheCreationInputTokens: safeNumber(usage.cache_creation_input_tokens),
|
||||||
},
|
},
|
||||||
turns,
|
turns: state.turns,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Claude Code stream-json (NDJSON) output.
|
||||||
|
* Each line is a JSON object with type: "system" | "assistant" | "user" | "result".
|
||||||
|
*/
|
||||||
|
export function parseClaudeCodeStreamOutput(stdout: string): ClaudeCodeParsedResult | null {
|
||||||
|
const lines = stdout.trim().split("\n");
|
||||||
|
const state: ParseState = { turns: [], resultLine: null, model: "", turnIndex: 0 };
|
||||||
|
for (const line of lines) {
|
||||||
|
processLine(line, state);
|
||||||
|
}
|
||||||
|
return assembleResult(state);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy: parse Claude Code plain JSON output (non-streaming).
|
* Legacy: parse Claude Code plain JSON output (non-streaming).
|
||||||
* Falls back when stream-json is not available.
|
* Falls back when stream-json is not available.
|
||||||
|
|||||||
@@ -4,6 +4,96 @@ import { HermesAcpClient } from "../src/acp-client.js";
|
|||||||
|
|
||||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
describe("handleSessionUpdate — helper extraction", () => {
|
||||||
|
let client: HermesAcpClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
client = new HermesAcpClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agent_message_chunk accumulates text in messageChunks", () => {
|
||||||
|
(client as any).handleSessionUpdate({
|
||||||
|
sessionUpdate: "agent_message_chunk",
|
||||||
|
content: { type: "text", text: "hello" },
|
||||||
|
});
|
||||||
|
(client as any).handleSessionUpdate({
|
||||||
|
sessionUpdate: "agent_message_chunk",
|
||||||
|
content: { type: "text", text: " world" },
|
||||||
|
});
|
||||||
|
expect((client as any).messageChunks).toEqual(["hello", " world"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agent_thought_chunk accumulates reasoning in reasoningChunks", () => {
|
||||||
|
(client as any).handleSessionUpdate({
|
||||||
|
sessionUpdate: "agent_thought_chunk",
|
||||||
|
content: { type: "text", text: "thinking" },
|
||||||
|
});
|
||||||
|
expect((client as any).reasoningChunks).toEqual(["thinking"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tool_call registers a pending tool and flushes message chunks", () => {
|
||||||
|
(client as any).messageChunks = ["pre-tool text"];
|
||||||
|
(client as any).handleSessionUpdate({
|
||||||
|
sessionUpdate: "tool_call",
|
||||||
|
title: "Bash",
|
||||||
|
rawInput: { command: "ls" },
|
||||||
|
toolCallId: "tc-1",
|
||||||
|
});
|
||||||
|
expect((client as any).pendingTools.get("tc-1")).toEqual({
|
||||||
|
name: "Bash",
|
||||||
|
args: JSON.stringify({ command: "ls" }),
|
||||||
|
});
|
||||||
|
expect((client as any).messageChunks).toEqual([]);
|
||||||
|
expect((client as any).messages).toHaveLength(1);
|
||||||
|
expect((client as any).messages[0].role).toBe("assistant");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tool_call_update completed pushes tool_call and tool messages", () => {
|
||||||
|
(client as any).pendingTools.set("tc-2", { name: "Read", args: '{"path":"/foo"}' });
|
||||||
|
(client as any).handleSessionUpdate({
|
||||||
|
sessionUpdate: "tool_call_update",
|
||||||
|
status: "completed",
|
||||||
|
toolCallId: "tc-2",
|
||||||
|
rawOutput: "file contents",
|
||||||
|
});
|
||||||
|
const msgs = (client as any).messages as Array<{
|
||||||
|
role: string;
|
||||||
|
tool_calls: unknown;
|
||||||
|
content: string | null;
|
||||||
|
}>;
|
||||||
|
expect(msgs).toHaveLength(2);
|
||||||
|
expect(msgs[0].role).toBe("assistant");
|
||||||
|
expect(msgs[0].tool_calls).toEqual([
|
||||||
|
{ function: { name: "Read", arguments: '{"path":"/foo"}' } },
|
||||||
|
]);
|
||||||
|
expect(msgs[1].role).toBe("tool");
|
||||||
|
expect(msgs[1].content).toBe("file contents");
|
||||||
|
expect((client as any).pendingTools.has("tc-2")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tool_call_update with non-string rawOutput JSON-stringifies it", () => {
|
||||||
|
(client as any).pendingTools.set("tc-3", { name: "Fetch", args: "" });
|
||||||
|
(client as any).handleSessionUpdate({
|
||||||
|
sessionUpdate: "tool_call_update",
|
||||||
|
status: "completed",
|
||||||
|
toolCallId: "tc-3",
|
||||||
|
rawOutput: { html: "<p>page</p>" },
|
||||||
|
});
|
||||||
|
const msgs = (client as any).messages as Array<{ role: string; content: string | null }>;
|
||||||
|
expect(msgs[1].content).toBe(JSON.stringify({ html: "<p>page</p>" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unknown updateType is a no-op", () => {
|
||||||
|
(client as any).handleSessionUpdate({ sessionUpdate: "unknown_type", data: {} });
|
||||||
|
expect((client as any).messages).toHaveLength(0);
|
||||||
|
expect((client as any).messageChunks).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("HermesAcpClient", () => {
|
describe("HermesAcpClient", () => {
|
||||||
let client: HermesAcpClient;
|
let client: HermesAcpClient;
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function makeCtx(overrides: Partial<AgentContext> = {}): AgentContext {
|
|||||||
graph: {},
|
graph: {},
|
||||||
},
|
},
|
||||||
role: "developer",
|
role: "developer",
|
||||||
start: { prompt: "Fix the bug", workflowHash: "abc123", threadId: "t1" },
|
start: { prompt: "Fix the bug", workflow: "abc123" },
|
||||||
steps: [],
|
steps: [],
|
||||||
store: {} as AgentContext["store"],
|
store: {} as AgentContext["store"],
|
||||||
outputFormatInstruction: "Use YAML frontmatter",
|
outputFormatInstruction: "Use YAML frontmatter",
|
||||||
@@ -55,6 +55,7 @@ describe("buildHermesPrompt", () => {
|
|||||||
agent: "uwf-hermes",
|
agent: "uwf-hermes",
|
||||||
detail: "detail-1",
|
detail: "detail-1",
|
||||||
edgePrompt: "Implement the fix.",
|
edgePrompt: "Implement the fix.",
|
||||||
|
content: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: "reviewer",
|
role: "reviewer",
|
||||||
@@ -62,6 +63,7 @@ describe("buildHermesPrompt", () => {
|
|||||||
agent: "uwf-hermes",
|
agent: "uwf-hermes",
|
||||||
detail: "detail-2",
|
detail: "detail-2",
|
||||||
edgePrompt: "Review the code.",
|
edgePrompt: "Review the code.",
|
||||||
|
content: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -85,6 +87,7 @@ describe("buildHermesPrompt", () => {
|
|||||||
agent: "uwf-hermes",
|
agent: "uwf-hermes",
|
||||||
detail: "detail-1",
|
detail: "detail-1",
|
||||||
edgePrompt: "First attempt.",
|
edgePrompt: "First attempt.",
|
||||||
|
content: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
edgePrompt: "Retry with a fresh approach.",
|
edgePrompt: "Retry with a fresh approach.",
|
||||||
@@ -95,4 +98,90 @@ describe("buildHermesPrompt", () => {
|
|||||||
expect(result).toContain("Retry with a fresh approach.");
|
expect(result).toContain("Retry with a fresh approach.");
|
||||||
expect(result).not.toContain("## What Happened Since Your Last Turn");
|
expect(result).not.toContain("## What Happened Since Your Last Turn");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("first visit includes content from previous steps", () => {
|
||||||
|
const ctx = makeCtx({
|
||||||
|
isFirstVisit: true,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
role: "planner",
|
||||||
|
output: { plan: "hash1" },
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
detail: "detail-1",
|
||||||
|
edgePrompt: "Create the plan.",
|
||||||
|
content: "# Plan\nDetailed plan markdown...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "developer",
|
||||||
|
output: { files: ["app.ts"] },
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
detail: "detail-2",
|
||||||
|
edgePrompt: "Implement the code.",
|
||||||
|
content: "# Implementation\nCode changes...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "reviewer",
|
||||||
|
output: { approved: true },
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
detail: "detail-3",
|
||||||
|
edgePrompt: "Review the work.",
|
||||||
|
content: "# Review\nApproved!",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
role: "committer",
|
||||||
|
edgePrompt: "Commit the reviewed code.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = buildHermesPrompt(ctx);
|
||||||
|
|
||||||
|
expect(result).toContain("Use YAML frontmatter");
|
||||||
|
expect(result).toContain("## Task");
|
||||||
|
expect(result).toContain("Fix the bug");
|
||||||
|
expect(result).toContain("## What Happened Since Your Last Turn");
|
||||||
|
expect(result).toContain("### Step 1: planner");
|
||||||
|
expect(result).toContain("#### Step Content");
|
||||||
|
expect(result).toContain("# Plan");
|
||||||
|
expect(result).toContain("Detailed plan markdown");
|
||||||
|
expect(result).toContain("### Step 2: developer");
|
||||||
|
expect(result).toContain("# Implementation");
|
||||||
|
expect(result).toContain("### Step 3: reviewer");
|
||||||
|
expect(result).toContain("# Review");
|
||||||
|
expect(result).toContain("## Moderator Instruction");
|
||||||
|
expect(result).toContain("Commit the reviewed code.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("re-entry omits content from previous steps", () => {
|
||||||
|
const ctx = makeCtx({
|
||||||
|
isFirstVisit: false,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
role: "developer",
|
||||||
|
output: { files: ["app.ts"] },
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
detail: "detail-1",
|
||||||
|
edgePrompt: "Implement the code.",
|
||||||
|
content: "# Implementation\nCode changes...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "reviewer",
|
||||||
|
output: { approved: false },
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
detail: "detail-2",
|
||||||
|
edgePrompt: "Review the work.",
|
||||||
|
content: "# Review\nNot approved!",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
role: "developer",
|
||||||
|
edgePrompt: "Fix the issues.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = buildHermesPrompt(ctx);
|
||||||
|
|
||||||
|
expect(result).toContain("## What Happened Since Your Last Turn");
|
||||||
|
expect(result).toContain("### Step 2: reviewer");
|
||||||
|
expect(result).toContain(JSON.stringify({ approved: false }));
|
||||||
|
expect(result).not.toContain("#### Step Content");
|
||||||
|
expect(result).not.toContain("# Review");
|
||||||
|
expect(result).not.toContain("Not approved!");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -245,72 +245,75 @@ export class HermesAcpClient {
|
|||||||
// ---- Session update → structured messages ----
|
// ---- Session update → structured messages ----
|
||||||
|
|
||||||
private handleSessionUpdate(update: Record<string, unknown>): void {
|
private handleSessionUpdate(update: Record<string, unknown>): void {
|
||||||
const updateType = update.sessionUpdate as string;
|
switch (update.sessionUpdate as string) {
|
||||||
|
case "agent_message_chunk":
|
||||||
switch (updateType) {
|
this.handleAgentMessageChunk(update);
|
||||||
case "agent_message_chunk": {
|
|
||||||
const content = update.content as { type?: string; text?: string } | undefined;
|
|
||||||
if (content?.type === "text" && typeof content.text === "string") {
|
|
||||||
this.messageChunks.push(content.text);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
case "agent_thought_chunk":
|
||||||
|
this.handleAgentThoughtChunk(update);
|
||||||
case "agent_thought_chunk": {
|
|
||||||
const content = update.content as { type?: string; text?: string } | undefined;
|
|
||||||
if (content?.type === "text" && typeof content.text === "string") {
|
|
||||||
this.reasoningChunks.push(content.text);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
case "tool_call":
|
||||||
|
this.handleToolCall(update);
|
||||||
case "tool_call": {
|
|
||||||
const title = (update.title as string) ?? "";
|
|
||||||
const rawInput = update.rawInput;
|
|
||||||
const args = rawInput !== undefined && rawInput !== null ? JSON.stringify(rawInput) : "";
|
|
||||||
const toolCallId = update.toolCallId as string;
|
|
||||||
this.pendingTools.set(toolCallId, { name: title, args });
|
|
||||||
|
|
||||||
// Flush accumulated assistant text before tool call
|
|
||||||
this.flushAssistantMessage();
|
|
||||||
break;
|
break;
|
||||||
}
|
case "tool_call_update":
|
||||||
|
this.handleToolCallUpdate(update);
|
||||||
case "tool_call_update": {
|
|
||||||
const status = update.status as string | undefined;
|
|
||||||
if (status === "completed" || status === "failed") {
|
|
||||||
const toolCallId = update.toolCallId as string;
|
|
||||||
const pending = this.pendingTools.get(toolCallId);
|
|
||||||
const toolName = pending?.name ?? toolCallId;
|
|
||||||
const rawOutput = update.rawOutput;
|
|
||||||
const outputStr =
|
|
||||||
rawOutput !== undefined && rawOutput !== null
|
|
||||||
? typeof rawOutput === "string"
|
|
||||||
? rawOutput
|
|
||||||
: JSON.stringify(rawOutput)
|
|
||||||
: "";
|
|
||||||
this.messages.push({
|
|
||||||
role: "assistant",
|
|
||||||
content: null,
|
|
||||||
reasoning: null,
|
|
||||||
tool_calls: [{ function: { name: toolName, arguments: pending?.args ?? "" } }],
|
|
||||||
});
|
|
||||||
this.messages.push({
|
|
||||||
role: "tool",
|
|
||||||
content: outputStr,
|
|
||||||
reasoning: null,
|
|
||||||
tool_calls: null,
|
|
||||||
});
|
|
||||||
this.pendingTools.delete(toolCallId);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleAgentMessageChunk(update: Record<string, unknown>): void {
|
||||||
|
const content = update.content as { type?: string; text?: string } | undefined;
|
||||||
|
if (content?.type === "text" && typeof content.text === "string") {
|
||||||
|
this.messageChunks.push(content.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleAgentThoughtChunk(update: Record<string, unknown>): void {
|
||||||
|
const content = update.content as { type?: string; text?: string } | undefined;
|
||||||
|
if (content?.type === "text" && typeof content.text === "string") {
|
||||||
|
this.reasoningChunks.push(content.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleToolCall(update: Record<string, unknown>): void {
|
||||||
|
const title = (update.title as string) ?? "";
|
||||||
|
const rawInput = update.rawInput;
|
||||||
|
const args = rawInput !== undefined && rawInput !== null ? JSON.stringify(rawInput) : "";
|
||||||
|
const toolCallId = update.toolCallId as string;
|
||||||
|
this.pendingTools.set(toolCallId, { name: title, args });
|
||||||
|
this.flushAssistantMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleToolCallUpdate(update: Record<string, unknown>): void {
|
||||||
|
const status = update.status as string | undefined;
|
||||||
|
if (status !== "completed" && status !== "failed") return;
|
||||||
|
const toolCallId = update.toolCallId as string;
|
||||||
|
const pending = this.pendingTools.get(toolCallId);
|
||||||
|
const toolName = pending?.name ?? toolCallId;
|
||||||
|
const rawOutput = update.rawOutput;
|
||||||
|
const outputStr =
|
||||||
|
rawOutput !== undefined && rawOutput !== null
|
||||||
|
? typeof rawOutput === "string"
|
||||||
|
? rawOutput
|
||||||
|
: JSON.stringify(rawOutput)
|
||||||
|
: "";
|
||||||
|
this.messages.push({
|
||||||
|
role: "assistant",
|
||||||
|
content: null,
|
||||||
|
reasoning: null,
|
||||||
|
tool_calls: [{ function: { name: toolName, arguments: pending?.args ?? "" } }],
|
||||||
|
});
|
||||||
|
this.messages.push({
|
||||||
|
role: "tool",
|
||||||
|
content: outputStr,
|
||||||
|
reasoning: null,
|
||||||
|
tool_calls: null,
|
||||||
|
});
|
||||||
|
this.pendingTools.delete(toolCallId);
|
||||||
|
}
|
||||||
|
|
||||||
/** Flush any accumulated text/reasoning into an assistant message. */
|
/** Flush any accumulated text/reasoning into an assistant message. */
|
||||||
private flushAssistantMessage(): void {
|
private flushAssistantMessage(): void {
|
||||||
const text = this.messageChunks.join("");
|
const text = this.messageChunks.join("");
|
||||||
|
|||||||
@@ -14,53 +14,39 @@ import { storeHermesSessionDetail } from "./session-detail.js";
|
|||||||
|
|
||||||
const log = createLogger({ sink: { kind: "stderr" } });
|
const log = createLogger({ sink: { kind: "stderr" } });
|
||||||
|
|
||||||
function buildHistorySummary(steps: AgentContext["steps"]): string {
|
/** Assemble system prompt, task, and prior step outputs for Hermes. */
|
||||||
if (steps.length === 0) {
|
export function buildHermesPrompt(ctx: AgentContext): string {
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines: string[] = ["## Previous Steps"];
|
|
||||||
for (let i = 0; i < steps.length; i++) {
|
|
||||||
const step = steps[i];
|
|
||||||
if (step === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
lines.push("");
|
|
||||||
lines.push(`### Step ${i + 1}: ${step.role}`);
|
|
||||||
lines.push(`Output: ${JSON.stringify(step.output)}`);
|
|
||||||
lines.push(`Agent: ${step.agent}`);
|
|
||||||
}
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildInitialPrompt(ctx: AgentContext): string {
|
|
||||||
const roleDef = ctx.workflow.roles[ctx.role];
|
|
||||||
const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
if (ctx.outputFormatInstruction !== "") {
|
if (ctx.outputFormatInstruction !== "") {
|
||||||
parts.push(ctx.outputFormatInstruction, "");
|
parts.push(ctx.outputFormatInstruction, "");
|
||||||
}
|
}
|
||||||
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
|
|
||||||
const historyBlock = buildHistorySummary(ctx.steps);
|
|
||||||
if (historyBlock !== "") {
|
|
||||||
parts.push("", historyBlock);
|
|
||||||
}
|
|
||||||
parts.push("", "## Moderator Instruction", "", ctx.edgePrompt);
|
|
||||||
return parts.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Assemble system prompt, task, and prior step outputs for Hermes. */
|
|
||||||
export function buildHermesPrompt(ctx: AgentContext): string {
|
|
||||||
if (!ctx.isFirstVisit) {
|
if (!ctx.isFirstVisit) {
|
||||||
const parts: string[] = [];
|
// Re-entry: show only steps since last visit, meta only
|
||||||
if (ctx.outputFormatInstruction !== "") {
|
|
||||||
parts.push(ctx.outputFormatInstruction, "");
|
|
||||||
}
|
|
||||||
parts.push(buildContinuationPrompt(ctx.steps, ctx.role, ctx.edgePrompt));
|
parts.push(buildContinuationPrompt(ctx.steps, ctx.role, ctx.edgePrompt));
|
||||||
return parts.join("\n");
|
return parts.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildInitialPrompt(ctx);
|
// First visit: show initial context with content for recent steps
|
||||||
|
const roleDef = ctx.workflow.roles[ctx.role];
|
||||||
|
const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
|
||||||
|
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
|
||||||
|
|
||||||
|
// Add history with content (last 2-3 steps within quota)
|
||||||
|
if (ctx.steps.length > 0) {
|
||||||
|
parts.push(
|
||||||
|
"",
|
||||||
|
buildContinuationPrompt(ctx.steps, ctx.role, ctx.edgePrompt, {
|
||||||
|
includeContent: true,
|
||||||
|
quota: 32000, // Use THREAD_READ_DEFAULT_QUOTA equivalent
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
parts.push("", "## Moderator Instruction", "", ctx.edgePrompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function storePromptResult(
|
async function storePromptResult(
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
// Re-export session cache from the shared agent-kit package.
|
// Re-export session cache from the shared agent-kit package with agent name injected.
|
||||||
export { getCachedSessionId, setCachedSessionId } from "@uncaged/workflow-agent-kit";
|
|
||||||
|
import {
|
||||||
|
getCachedSessionId as getCachedSessionIdBase,
|
||||||
|
setCachedSessionId as setCachedSessionIdBase,
|
||||||
|
} from "@uncaged/workflow-agent-kit";
|
||||||
|
import type { ThreadId } from "@uncaged/workflow-protocol";
|
||||||
|
|
||||||
|
export async function getCachedSessionId(threadId: ThreadId, role: string): Promise<string | null> {
|
||||||
|
return getCachedSessionIdBase("hermes", threadId, role);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setCachedSessionId(
|
||||||
|
threadId: ThreadId,
|
||||||
|
role: string,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return setCachedSessionIdBase("hermes", threadId, role, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
export function isResumeDisabled(): boolean {
|
export function isResumeDisabled(): boolean {
|
||||||
// Hermes ACP session/resume is broken: _restore fails for custom providers
|
// Hermes ACP session/resume is broken: _restore fails for custom providers
|
||||||
|
|||||||
@@ -83,9 +83,10 @@ Requires `UWF_EDGE_PROMPT` in the environment (set by `uwf thread step`).
|
|||||||
function buildRolePrompt(role: RoleDefinition): string
|
function buildRolePrompt(role: RoleDefinition): string
|
||||||
function buildOutputFormatInstruction(schema: JSONSchema): string
|
function buildOutputFormatInstruction(schema: JSONSchema): string
|
||||||
function buildContinuationPrompt(
|
function buildContinuationPrompt(
|
||||||
ctx: AgentContext,
|
steps: StepContext[],
|
||||||
priorOutput: string,
|
role: string,
|
||||||
instruction: string,
|
edgePrompt: string,
|
||||||
|
options?: { includeContent?: boolean; quota?: number },
|
||||||
): string
|
): string
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const reviewerStep: StepContext = {
|
|||||||
detail: "2MXBG6PN4A8JR",
|
detail: "2MXBG6PN4A8JR",
|
||||||
agent: "uwf-hermes",
|
agent: "uwf-hermes",
|
||||||
edgePrompt: "Review the developer's work.",
|
edgePrompt: "Review the developer's work.",
|
||||||
|
content: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const developerStep: StepContext = {
|
const developerStep: StepContext = {
|
||||||
@@ -16,6 +17,7 @@ const developerStep: StepContext = {
|
|||||||
detail: "1VPBG9SM5E7WK",
|
detail: "1VPBG9SM5E7WK",
|
||||||
agent: "uwf-hermes",
|
agent: "uwf-hermes",
|
||||||
edgePrompt: "Implement the fix.",
|
edgePrompt: "Implement the fix.",
|
||||||
|
content: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("buildContinuationPrompt", () => {
|
describe("buildContinuationPrompt", () => {
|
||||||
@@ -29,6 +31,7 @@ describe("buildContinuationPrompt", () => {
|
|||||||
detail: "7BQST3VW9F2MA",
|
detail: "7BQST3VW9F2MA",
|
||||||
agent: "uwf-hermes",
|
agent: "uwf-hermes",
|
||||||
edgePrompt: "Revise the plan.",
|
edgePrompt: "Revise the plan.",
|
||||||
|
content: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -70,4 +73,162 @@ describe("buildContinuationPrompt", () => {
|
|||||||
expect(result).toContain("## Moderator Instruction");
|
expect(result).toContain("## Moderator Instruction");
|
||||||
expect(result).toContain("Please revise your work.");
|
expect(result).toContain("Please revise your work.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("includes step content when includeContent option is true", () => {
|
||||||
|
const stepsWithContent: StepContext[] = [
|
||||||
|
{
|
||||||
|
role: "planner",
|
||||||
|
output: { plan: "hash123" },
|
||||||
|
detail: "detail1",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: "# Plan\nDetailed plan markdown...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "developer",
|
||||||
|
output: { filesChanged: ["app.ts"] },
|
||||||
|
detail: "detail2",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: "# Implementation\nCode changes...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "reviewer",
|
||||||
|
output: { approved: false },
|
||||||
|
detail: "detail3",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: "# Review\nFeedback...",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = buildContinuationPrompt(stepsWithContent, "committer", "Commit the changes.", {
|
||||||
|
includeContent: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toContain("## What Happened Since Your Last Turn");
|
||||||
|
expect(result).toContain("### Step 1: planner");
|
||||||
|
expect(result).toContain("#### Step Content");
|
||||||
|
expect(result).toContain("# Plan");
|
||||||
|
expect(result).toContain("Detailed plan markdown");
|
||||||
|
expect(result).toContain("### Step 2: developer");
|
||||||
|
expect(result).toContain("# Implementation");
|
||||||
|
expect(result).toContain("### Step 3: reviewer");
|
||||||
|
expect(result).toContain("# Review");
|
||||||
|
expect(result).toContain("## Moderator Instruction");
|
||||||
|
expect(result).toContain("Commit the changes.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("omits step content when includeContent is false (default)", () => {
|
||||||
|
const stepsWithContent: StepContext[] = [
|
||||||
|
{
|
||||||
|
role: "developer",
|
||||||
|
output: { filesChanged: ["app.ts"] },
|
||||||
|
detail: "detail1",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: "# Implementation\nCode changes...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "reviewer",
|
||||||
|
output: { approved: false },
|
||||||
|
detail: "detail2",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: "# Review\nFeedback...",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = buildContinuationPrompt(stepsWithContent, "developer", "Fix the issues.");
|
||||||
|
|
||||||
|
expect(result).toContain("## What Happened Since Your Last Turn");
|
||||||
|
expect(result).toContain("### Step 2: reviewer");
|
||||||
|
expect(result).toContain(JSON.stringify(stepsWithContent[1]?.output));
|
||||||
|
expect(result).not.toContain("#### Step Content");
|
||||||
|
expect(result).not.toContain("# Review");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("respects quota when includeContent is true", () => {
|
||||||
|
const largeContent = "x".repeat(5000);
|
||||||
|
const stepsWithContent: StepContext[] = [
|
||||||
|
{
|
||||||
|
role: "planner",
|
||||||
|
output: { plan: "hash1" },
|
||||||
|
detail: "detail1",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: largeContent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "developer",
|
||||||
|
output: { files: ["app.ts"] },
|
||||||
|
detail: "detail2",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: largeContent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "reviewer",
|
||||||
|
output: { approved: true },
|
||||||
|
detail: "detail3",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: "# Review\nLooks good!",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = buildContinuationPrompt(stepsWithContent, "committer", "Commit the changes.", {
|
||||||
|
includeContent: true,
|
||||||
|
quota: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should include most recent step(s) within quota
|
||||||
|
expect(result).toContain("### Step 1: reviewer"); // Showing 1 of 3, so step 3 becomes step 1
|
||||||
|
expect(result).toContain("#### Step Content");
|
||||||
|
expect(result).toContain("## Moderator Instruction");
|
||||||
|
expect(result).toContain("Showing 1 of 3 steps (2 omitted due to quota)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles null content gracefully when includeContent is true", () => {
|
||||||
|
const stepsWithMixedContent: StepContext[] = [
|
||||||
|
{
|
||||||
|
role: "planner",
|
||||||
|
output: { plan: "hash1" },
|
||||||
|
detail: "detail1",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: "# Plan\nDetails...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "developer",
|
||||||
|
output: { files: ["app.ts"] },
|
||||||
|
detail: "detail2",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: null, // No content available
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "reviewer",
|
||||||
|
output: { approved: true },
|
||||||
|
detail: "detail3",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
edgePrompt: "",
|
||||||
|
content: "# Review\nApproved!",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = buildContinuationPrompt(
|
||||||
|
stepsWithMixedContent,
|
||||||
|
"committer",
|
||||||
|
"Commit the changes.",
|
||||||
|
{ includeContent: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toContain("### Step 1: planner");
|
||||||
|
expect(result).toContain("# Plan");
|
||||||
|
expect(result).toContain("### Step 2: developer");
|
||||||
|
// Step 2 should not have content section since content is null
|
||||||
|
expect(result).toContain("### Step 3: reviewer");
|
||||||
|
expect(result).toContain("# Review");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
// We need to test buildHistory indirectly through buildContext
|
||||||
|
// since buildHistory is not exported. For now, we'll test the integration
|
||||||
|
// through the public API in a separate integration test.
|
||||||
|
|
||||||
|
describe("context module - content extraction", () => {
|
||||||
|
test("placeholder - content extraction will be tested via integration tests", () => {
|
||||||
|
// This test is a placeholder. The actual testing of content extraction
|
||||||
|
// will be done through integration tests in build-continuation-prompt.test.ts
|
||||||
|
// where we can verify that StepContext objects have the correct content field.
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
describe("session-cache", () => {
|
||||||
|
let originalStorageRoot: string;
|
||||||
|
let testStorageRoot: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create a temporary test storage root
|
||||||
|
originalStorageRoot = resolveStorageRoot();
|
||||||
|
testStorageRoot = join(originalStorageRoot, "test-cache", `test-${Date.now()}`);
|
||||||
|
await mkdir(testStorageRoot, { recursive: true });
|
||||||
|
|
||||||
|
// Override the storage root for testing
|
||||||
|
process.env.WORKFLOW_STORAGE_ROOT = testStorageRoot;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Clean up test storage root
|
||||||
|
await rm(testStorageRoot, { recursive: true, force: true });
|
||||||
|
delete process.env.WORKFLOW_STORAGE_ROOT;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCachePath", () => {
|
||||||
|
test("returns agent-specific file path", () => {
|
||||||
|
const path = getCachePath("claude-code");
|
||||||
|
expect(path).toMatch(/\/cache\/claude-code-sessions\.json$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns different paths for different agents", () => {
|
||||||
|
const pathClaudeCode = getCachePath("claude-code");
|
||||||
|
const pathHermes = getCachePath("hermes");
|
||||||
|
|
||||||
|
expect(pathClaudeCode).not.toBe(pathHermes);
|
||||||
|
expect(pathClaudeCode).toMatch(/claude-code-sessions\.json$/);
|
||||||
|
expect(pathHermes).toMatch(/hermes-sessions\.json$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles agent names with special characters", () => {
|
||||||
|
const path1 = getCachePath("my-agent");
|
||||||
|
const path2 = getCachePath("my_agent");
|
||||||
|
|
||||||
|
expect(path1).toMatch(/my-agent-sessions\.json$/);
|
||||||
|
expect(path2).toMatch(/my_agent-sessions\.json$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("session isolation", () => {
|
||||||
|
const threadId = "01234567890123456789012345" as ThreadId;
|
||||||
|
const role = "developer";
|
||||||
|
|
||||||
|
test("sessions are isolated per agent", async () => {
|
||||||
|
// Cache different session IDs for each agent
|
||||||
|
await setCachedSessionId("claude-code", threadId, role, "session-cc-001");
|
||||||
|
await setCachedSessionId("hermes", threadId, role, "session-hermes-001");
|
||||||
|
|
||||||
|
// Each agent should retrieve its own session ID
|
||||||
|
const sessionCC = await getCachedSessionId("claude-code", threadId, role);
|
||||||
|
const sessionHermes = await getCachedSessionId("hermes", threadId, role);
|
||||||
|
|
||||||
|
expect(sessionCC).toBe("session-cc-001");
|
||||||
|
expect(sessionHermes).toBe("session-hermes-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updating one agent's cache does not affect another", async () => {
|
||||||
|
// Set initial sessions for both agents
|
||||||
|
await setCachedSessionId("claude-code", threadId, role, "session-cc-001");
|
||||||
|
await setCachedSessionId("hermes", threadId, role, "session-hermes-001");
|
||||||
|
|
||||||
|
// Update claude-code's session
|
||||||
|
await setCachedSessionId("claude-code", threadId, role, "session-cc-002");
|
||||||
|
|
||||||
|
// Hermes's session should remain unchanged
|
||||||
|
const sessionHermes = await getCachedSessionId("hermes", threadId, role);
|
||||||
|
expect(sessionHermes).toBe("session-hermes-001");
|
||||||
|
|
||||||
|
// Claude-code should have the new session
|
||||||
|
const sessionCC = await getCachedSessionId("claude-code", threadId, role);
|
||||||
|
expect(sessionCC).toBe("session-cc-002");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing session returns null for specific agent", async () => {
|
||||||
|
const session = await getCachedSessionId("claude-code", threadId, role);
|
||||||
|
expect(session).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty session ID is treated as missing", async () => {
|
||||||
|
await setCachedSessionId("claude-code", threadId, role, "");
|
||||||
|
|
||||||
|
const session = await getCachedSessionId("claude-code", threadId, role);
|
||||||
|
expect(session).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("file system operations", () => {
|
||||||
|
const threadId = "01234567890123456789012345" as ThreadId;
|
||||||
|
const role = "developer";
|
||||||
|
|
||||||
|
test("cache directory is created if missing", async () => {
|
||||||
|
const cachePath = getCachePath("claude-code");
|
||||||
|
const cacheDir = dirname(cachePath);
|
||||||
|
|
||||||
|
// Ensure cache dir doesn't exist
|
||||||
|
await rm(cacheDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
// Write a session
|
||||||
|
await setCachedSessionId("claude-code", threadId, role, "session-001");
|
||||||
|
|
||||||
|
// Cache directory should be created
|
||||||
|
const stats = await stat(cacheDir);
|
||||||
|
expect(stats.isDirectory()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multiple agents create separate cache files", async () => {
|
||||||
|
// Cache sessions for multiple agents
|
||||||
|
await setCachedSessionId("claude-code", threadId, role, "session-cc-001");
|
||||||
|
await setCachedSessionId("hermes", threadId, role, "session-hermes-001");
|
||||||
|
|
||||||
|
// Separate cache files should exist
|
||||||
|
const pathCC = getCachePath("claude-code");
|
||||||
|
const pathHermes = getCachePath("hermes");
|
||||||
|
|
||||||
|
const contentCC = JSON.parse(await readFile(pathCC, "utf8")) as Record<string, string>;
|
||||||
|
const contentHermes = JSON.parse(await readFile(pathHermes, "utf8")) as Record<
|
||||||
|
string,
|
||||||
|
string
|
||||||
|
>;
|
||||||
|
|
||||||
|
expect(contentCC).toHaveProperty(`${threadId}:${role}`, "session-cc-001");
|
||||||
|
expect(contentHermes).toHaveProperty(`${threadId}:${role}`, "session-hermes-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("atomic writes prevent partial reads", async () => {
|
||||||
|
// Write a session
|
||||||
|
await setCachedSessionId("claude-code", threadId, role, "session-001");
|
||||||
|
|
||||||
|
// The final file should exist (no .tmp files left behind)
|
||||||
|
const cachePath = getCachePath("claude-code");
|
||||||
|
const dir = dirname(cachePath);
|
||||||
|
const files = await readdir(dir);
|
||||||
|
|
||||||
|
expect(files).toContain("claude-code-sessions.json");
|
||||||
|
expect(files.every((f) => !f.endsWith(".tmp"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("legacy migration", () => {
|
||||||
|
const threadId = "01234567890123456789012345" as ThreadId;
|
||||||
|
const role = "developer";
|
||||||
|
|
||||||
|
test("old agent-sessions.json is ignored", async () => {
|
||||||
|
// Create old agent-sessions.json file
|
||||||
|
const oldCachePath = join(resolveStorageRoot(), "cache", "agent-sessions.json");
|
||||||
|
await mkdir(dirname(oldCachePath), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
oldCachePath,
|
||||||
|
JSON.stringify({
|
||||||
|
"01234567890123456789012345:developer": "old-session-001",
|
||||||
|
}),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Query with the new per-agent cache
|
||||||
|
const session = await getCachedSessionId("claude-code", threadId, role);
|
||||||
|
|
||||||
|
// Should return null (old cache is ignored)
|
||||||
|
expect(session).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("new per-agent cache takes precedence", async () => {
|
||||||
|
// Create both old and new cache files
|
||||||
|
const oldPath = join(resolveStorageRoot(), "cache", "agent-sessions.json");
|
||||||
|
await mkdir(dirname(oldPath), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
oldPath,
|
||||||
|
JSON.stringify({
|
||||||
|
[`${threadId}:${role}`]: "old-session",
|
||||||
|
}),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
await setCachedSessionId("claude-code", threadId, role, "new-session");
|
||||||
|
|
||||||
|
// The new per-agent cache value should be returned
|
||||||
|
const session = await getCachedSessionId("claude-code", threadId, role);
|
||||||
|
expect(session).toBe("new-session");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("error handling", () => {
|
||||||
|
const threadId = "01234567890123456789012345" as ThreadId;
|
||||||
|
const role = "developer";
|
||||||
|
|
||||||
|
test("invalid JSON in cache file returns empty cache", async () => {
|
||||||
|
// Create a corrupted cache file
|
||||||
|
const cachePath = getCachePath("claude-code");
|
||||||
|
await mkdir(dirname(cachePath), { recursive: true });
|
||||||
|
await writeFile(cachePath, "{ invalid json }", "utf8");
|
||||||
|
|
||||||
|
// Should return null (treating corrupted cache as empty)
|
||||||
|
const session = await getCachedSessionId("claude-code", threadId, role);
|
||||||
|
expect(session).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-object JSON in cache file returns empty cache", async () => {
|
||||||
|
// Create a cache file with non-object JSON
|
||||||
|
const cachePath = getCachePath("claude-code");
|
||||||
|
await mkdir(dirname(cachePath), { recursive: true });
|
||||||
|
await writeFile(cachePath, JSON.stringify(["not", "an", "object"]), "utf8");
|
||||||
|
|
||||||
|
// Should return null
|
||||||
|
const session = await getCachedSessionId("claude-code", threadId, role);
|
||||||
|
expect(session).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cache entries with non-string values are ignored", async () => {
|
||||||
|
// Create a cache file with mixed types
|
||||||
|
const cachePath = getCachePath("claude-code");
|
||||||
|
const cacheData = {
|
||||||
|
"thread1:role1": "valid-session",
|
||||||
|
"thread2:role2": 12345, // number
|
||||||
|
"thread3:role3": null, // null
|
||||||
|
"thread4:role4": "", // empty string
|
||||||
|
};
|
||||||
|
await mkdir(dirname(cachePath), { recursive: true });
|
||||||
|
await writeFile(cachePath, JSON.stringify(cacheData), "utf8");
|
||||||
|
|
||||||
|
// Valid string entries should be returned
|
||||||
|
const session1 = await getCachedSessionId("claude-code", "thread1" as ThreadId, "role1");
|
||||||
|
expect(session1).toBe("valid-session");
|
||||||
|
|
||||||
|
// Invalid entries should return null
|
||||||
|
const session2 = await getCachedSessionId("claude-code", "thread2" as ThreadId, "role2");
|
||||||
|
const session3 = await getCachedSessionId("claude-code", "thread3" as ThreadId, "role3");
|
||||||
|
const session4 = await getCachedSessionId("claude-code", "thread4" as ThreadId, "role4");
|
||||||
|
|
||||||
|
expect(session2).toBeNull();
|
||||||
|
expect(session3).toBeNull();
|
||||||
|
expect(session4).toBeNull(); // empty string is treated as missing
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,20 @@
|
|||||||
import type { StepContext } from "@uncaged/workflow-protocol";
|
import type { StepContext } from "@uncaged/workflow-protocol";
|
||||||
|
|
||||||
function formatStep(step: StepContext, stepNumber: number): string {
|
function formatStep(step: StepContext, stepNumber: number, includeContent: boolean): string {
|
||||||
return [
|
const lines = [
|
||||||
`### Step ${stepNumber}: ${step.role}`,
|
`### Step ${stepNumber}: ${step.role}`,
|
||||||
`Output: ${JSON.stringify(step.output)}`,
|
`Output: ${JSON.stringify(step.output)}`,
|
||||||
`Agent: ${step.agent}`,
|
`Agent: ${step.agent}`,
|
||||||
].join("\n");
|
];
|
||||||
|
|
||||||
|
if (includeContent && step.content !== null) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("#### Step Content");
|
||||||
|
lines.push("");
|
||||||
|
lines.push(step.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function findLastRoleIndex(steps: StepContext[], role: string): number {
|
function findLastRoleIndex(steps: StepContext[], role: string): number {
|
||||||
@@ -18,6 +27,45 @@ function findLastRoleIndex(steps: StepContext[], role: string): number {
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectStepsWithinQuota(steps: StepContext[], quota: number): StepContext[] {
|
||||||
|
const selected: StepContext[] = [];
|
||||||
|
let totalChars = 0;
|
||||||
|
|
||||||
|
// Work backwards (newest first)
|
||||||
|
for (let i = steps.length - 1; i >= 0; i--) {
|
||||||
|
const step = steps[i];
|
||||||
|
if (step === undefined) continue;
|
||||||
|
|
||||||
|
// Estimate size: meta + content
|
||||||
|
const metaSize = JSON.stringify({
|
||||||
|
role: step.role,
|
||||||
|
output: step.output,
|
||||||
|
agent: step.agent,
|
||||||
|
}).length;
|
||||||
|
const contentSize = step.content?.length ?? 0;
|
||||||
|
const stepSize = metaSize + contentSize;
|
||||||
|
|
||||||
|
if (totalChars + stepSize > quota && selected.length > 0) {
|
||||||
|
// Stop adding steps but keep at least 1
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
selected.unshift(step); // Keep chronological order
|
||||||
|
totalChars += stepSize;
|
||||||
|
|
||||||
|
if (totalChars >= quota) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BuildContinuationPromptOptions = {
|
||||||
|
includeContent?: boolean;
|
||||||
|
quota?: number;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a continuation prompt for a role re-entry.
|
* Build a continuation prompt for a role re-entry.
|
||||||
*
|
*
|
||||||
@@ -28,7 +76,11 @@ export function buildContinuationPrompt(
|
|||||||
steps: StepContext[],
|
steps: StepContext[],
|
||||||
role: string,
|
role: string,
|
||||||
edgePrompt: string,
|
edgePrompt: string,
|
||||||
|
options?: BuildContinuationPromptOptions,
|
||||||
): string {
|
): string {
|
||||||
|
const includeContent = options?.includeContent ?? false;
|
||||||
|
const quota = options?.quota ?? Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
const lastIndex = findLastRoleIndex(steps, role);
|
const lastIndex = findLastRoleIndex(steps, role);
|
||||||
const sinceSteps = lastIndex >= 0 ? steps.slice(lastIndex + 1) : steps;
|
const sinceSteps = lastIndex >= 0 ? steps.slice(lastIndex + 1) : steps;
|
||||||
|
|
||||||
@@ -37,13 +89,25 @@ export function buildContinuationPrompt(
|
|||||||
if (sinceSteps.length > 0) {
|
if (sinceSteps.length > 0) {
|
||||||
parts.push("## What Happened Since Your Last Turn");
|
parts.push("## What Happened Since Your Last Turn");
|
||||||
const baseStepNumber = lastIndex >= 0 ? lastIndex + 2 : 1;
|
const baseStepNumber = lastIndex >= 0 ? lastIndex + 2 : 1;
|
||||||
for (let i = 0; i < sinceSteps.length; i++) {
|
|
||||||
const step = sinceSteps[i];
|
// Select steps within quota (newest-first if includeContent = true)
|
||||||
|
const selectedSteps = includeContent ? selectStepsWithinQuota(sinceSteps, quota) : sinceSteps;
|
||||||
|
|
||||||
|
const skippedCount = sinceSteps.length - selectedSteps.length;
|
||||||
|
if (skippedCount > 0) {
|
||||||
|
parts.push("");
|
||||||
|
parts.push(
|
||||||
|
`_Showing ${selectedSteps.length} of ${sinceSteps.length} steps (${skippedCount} omitted due to quota)_`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < selectedSteps.length; i++) {
|
||||||
|
const step = selectedSteps[i];
|
||||||
if (step === undefined) {
|
if (step === undefined) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
parts.push("");
|
parts.push("");
|
||||||
parts.push(formatStep(step, baseStepNumber + i));
|
parts.push(formatStep(step, baseStepNumber + i, includeContent));
|
||||||
}
|
}
|
||||||
parts.push("");
|
parts.push("");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,14 +21,6 @@ function fail(message: string): never {
|
|||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readEdgePrompt(): string {
|
|
||||||
const value = process.env.UWF_EDGE_PROMPT;
|
|
||||||
if (value === undefined || value === "") {
|
|
||||||
fail("UWF_EDGE_PROMPT environment variable is required");
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function walkChain(store: Store, schemas: AgentStore["schemas"], headHash: CasRef): ChainState {
|
function walkChain(store: Store, schemas: AgentStore["schemas"], headHash: CasRef): ChainState {
|
||||||
const headNode = store.get(headHash);
|
const headNode = store.get(headHash);
|
||||||
if (headNode === null) {
|
if (headNode === null) {
|
||||||
@@ -90,6 +82,38 @@ function expandOutput(store: Store, outputRef: CasRef): unknown {
|
|||||||
return node.payload;
|
return node.payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractStepContent(store: Store, detailRef: CasRef): string | null {
|
||||||
|
const detailNode = store.get(detailRef);
|
||||||
|
if (detailNode === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const detail = detailNode.payload as Record<string, unknown>;
|
||||||
|
const turns = detail.turns;
|
||||||
|
if (!Array.isArray(turns) || turns.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Find last assistant content (same logic as extractLastAssistantContent in cli-workflow)
|
||||||
|
for (let i = turns.length - 1; i >= 0; i--) {
|
||||||
|
const turnRef = turns[i];
|
||||||
|
if (typeof turnRef !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const turnNode = store.get(turnRef as CasRef);
|
||||||
|
if (turnNode === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const turn = turnNode.payload as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
turn.role === "assistant" &&
|
||||||
|
typeof turn.content === "string" &&
|
||||||
|
turn.content.trim() !== ""
|
||||||
|
) {
|
||||||
|
return turn.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function buildHistory(
|
async function buildHistory(
|
||||||
store: Store,
|
store: Store,
|
||||||
stepsNewestFirst: StepNodePayload[],
|
stepsNewestFirst: StepNodePayload[],
|
||||||
@@ -97,12 +121,14 @@ async function buildHistory(
|
|||||||
const chronological = [...stepsNewestFirst].reverse();
|
const chronological = [...stepsNewestFirst].reverse();
|
||||||
const history: StepContext[] = [];
|
const history: StepContext[] = [];
|
||||||
for (const step of chronological) {
|
for (const step of chronological) {
|
||||||
|
const content = extractStepContent(store, step.detail);
|
||||||
history.push({
|
history.push({
|
||||||
role: step.role,
|
role: step.role,
|
||||||
output: expandOutput(store, step.output),
|
output: expandOutput(store, step.output),
|
||||||
detail: step.detail,
|
detail: step.detail,
|
||||||
agent: step.agent,
|
agent: step.agent,
|
||||||
edgePrompt: step.edgePrompt ?? "",
|
edgePrompt: step.edgePrompt ?? "",
|
||||||
|
content,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return history;
|
return history;
|
||||||
@@ -123,7 +149,11 @@ async function loadWorkflow(store: Store, schemas: AgentStore["schemas"], workfl
|
|||||||
* Build agent execution context from thread head in threads.yaml.
|
* Build agent execution context from thread head in threads.yaml.
|
||||||
* Walks the CAS chain from head to StartNode and expands step outputs.
|
* Walks the CAS chain from head to StartNode and expands step outputs.
|
||||||
*/
|
*/
|
||||||
export async function buildContext(threadId: ThreadId, role: string): Promise<AgentContext> {
|
export async function buildContext(
|
||||||
|
threadId: ThreadId,
|
||||||
|
role: string,
|
||||||
|
edgePrompt: string,
|
||||||
|
): Promise<AgentContext> {
|
||||||
const storageRoot = resolveStorageRoot();
|
const storageRoot = resolveStorageRoot();
|
||||||
const agentStore = await createAgentStore(storageRoot);
|
const agentStore = await createAgentStore(storageRoot);
|
||||||
const { store, schemas } = agentStore;
|
const { store, schemas } = agentStore;
|
||||||
@@ -142,7 +172,6 @@ export async function buildContext(threadId: ThreadId, role: string): Promise<Ag
|
|||||||
}
|
}
|
||||||
|
|
||||||
const steps = await buildHistory(store, chain.stepsNewestFirst);
|
const steps = await buildHistory(store, chain.stepsNewestFirst);
|
||||||
const edgePrompt = readEdgePrompt();
|
|
||||||
const isFirstVisit = !steps.some((s) => s.role === role);
|
const isFirstVisit = !steps.some((s) => s.role === role);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -172,6 +201,7 @@ export type BuildContextMeta = {
|
|||||||
export async function buildContextWithMeta(
|
export async function buildContextWithMeta(
|
||||||
threadId: ThreadId,
|
threadId: ThreadId,
|
||||||
role: string,
|
role: string,
|
||||||
|
edgePrompt: string,
|
||||||
): Promise<AgentContext & { meta: BuildContextMeta }> {
|
): Promise<AgentContext & { meta: BuildContextMeta }> {
|
||||||
const storageRoot = resolveStorageRoot();
|
const storageRoot = resolveStorageRoot();
|
||||||
const agentStore = await createAgentStore(storageRoot);
|
const agentStore = await createAgentStore(storageRoot);
|
||||||
@@ -191,7 +221,6 @@ export async function buildContextWithMeta(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const steps = await buildHistory(store, chain.stepsNewestFirst);
|
const steps = await buildHistory(store, chain.stepsNewestFirst);
|
||||||
const edgePrompt = readEdgePrompt();
|
|
||||||
const isFirstVisit = !steps.some((s) => s.role === role);
|
const isFirstVisit = !steps.some((s) => s.role === role);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export {
|
|||||||
export type { FrontmatterFastPathResult } from "./frontmatter.js";
|
export type { FrontmatterFastPathResult } from "./frontmatter.js";
|
||||||
export { tryFrontmatterFastPath } from "./frontmatter.js";
|
export { tryFrontmatterFastPath } from "./frontmatter.js";
|
||||||
export { createAgent } from "./run.js";
|
export { createAgent } from "./run.js";
|
||||||
export { getCachedSessionId, setCachedSessionId } from "./session-cache.js";
|
export { getCachedSessionId, getCachePath, setCachedSessionId } from "./session-cache.js";
|
||||||
export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
|
export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
|
||||||
export type {
|
export type {
|
||||||
AgentContext,
|
AgentContext,
|
||||||
|
|||||||
@@ -22,16 +22,24 @@ function agentLabel(name: string): string {
|
|||||||
return `uwf-${name}`;
|
return `uwf-${name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseArgv(argv: string[]): { threadId: ThreadId; role: string } {
|
const USAGE = "usage: <agent-cli> --thread <id> --role <role> --prompt <text>";
|
||||||
const threadId = argv[2];
|
|
||||||
const role = argv[3];
|
function getNamedArg(argv: string[], name: string): string {
|
||||||
if (threadId === undefined || threadId === "") {
|
const idx = argv.indexOf(name);
|
||||||
fail("usage: <agent-cli> <thread-id> <role>");
|
if (idx === -1 || idx + 1 >= argv.length) {
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
if (role === undefined || role === "") {
|
return argv[idx + 1];
|
||||||
fail("usage: <agent-cli> <thread-id> <role>");
|
}
|
||||||
}
|
|
||||||
return { threadId: threadId as ThreadId, role };
|
function parseArgv(argv: string[]): { threadId: ThreadId; role: string; prompt: string } {
|
||||||
|
const threadId = getNamedArg(argv, "--thread");
|
||||||
|
const role = getNamedArg(argv, "--role");
|
||||||
|
const prompt = getNamedArg(argv, "--prompt");
|
||||||
|
if (threadId === "") fail(USAGE);
|
||||||
|
if (role === "") fail(USAGE);
|
||||||
|
if (prompt === "") fail(USAGE);
|
||||||
|
return { threadId: threadId as ThreadId, role, prompt };
|
||||||
}
|
}
|
||||||
|
|
||||||
function runWithMessage<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
function runWithMessage<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||||
@@ -103,11 +111,11 @@ async function persistStep(options: {
|
|||||||
|
|
||||||
export function createAgent(options: AgentOptions): () => Promise<void> {
|
export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||||
return async function main(): Promise<void> {
|
return async function main(): Promise<void> {
|
||||||
const { threadId, role } = parseArgv(process.argv);
|
const { threadId, role, prompt } = parseArgv(process.argv);
|
||||||
const storageRoot = resolveStorageRoot();
|
const storageRoot = resolveStorageRoot();
|
||||||
loadDotenv({ path: getEnvPath(storageRoot) });
|
loadDotenv({ path: getEnvPath(storageRoot) });
|
||||||
|
|
||||||
const ctx = await runWithMessage("context", () => buildContextWithMeta(threadId, role));
|
const ctx = await runWithMessage("context", () => buildContextWithMeta(threadId, role, prompt));
|
||||||
|
|
||||||
const roleDef = ctx.workflow.roles[role];
|
const roleDef = ctx.workflow.roles[role];
|
||||||
if (roleDef === undefined) {
|
if (roleDef === undefined) {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { resolveStorageRoot } from "./storage.js";
|
|||||||
|
|
||||||
type SessionCache = Record<string, string>;
|
type SessionCache = Record<string, string>;
|
||||||
|
|
||||||
function getCachePath(): string {
|
export function getCachePath(agentName: string): string {
|
||||||
return join(resolveStorageRoot(), "cache", "agent-sessions.json");
|
return join(resolveStorageRoot(), "cache", `${agentName}-sessions.json`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cacheKey(threadId: ThreadId, role: string): string {
|
function cacheKey(threadId: ThreadId, role: string): string {
|
||||||
@@ -20,8 +20,8 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readCache(): Promise<SessionCache> {
|
async function readCache(agentName: string): Promise<SessionCache> {
|
||||||
const path = getCachePath();
|
const path = getCachePath(agentName);
|
||||||
try {
|
try {
|
||||||
const text = await readFile(path, "utf8");
|
const text = await readFile(path, "utf8");
|
||||||
const raw = JSON.parse(text) as unknown;
|
const raw = JSON.parse(text) as unknown;
|
||||||
@@ -40,36 +40,45 @@ async function readCache(): Promise<SessionCache> {
|
|||||||
if (err.code === "ENOENT") {
|
if (err.code === "ENOENT") {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
// Treat JSON parse errors as empty cache
|
||||||
|
if (err.name === "SyntaxError") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeCache(cache: SessionCache): Promise<void> {
|
async function writeCache(agentName: string, cache: SessionCache): Promise<void> {
|
||||||
const path = getCachePath();
|
const path = getCachePath(agentName);
|
||||||
const dir = dirname(path);
|
const dir = dirname(path);
|
||||||
await mkdir(dir, { recursive: true });
|
await mkdir(dir, { recursive: true });
|
||||||
// Atomic write: write to temp file then rename to avoid partial reads on concurrent access.
|
// Atomic write: write to temp file then rename to avoid partial reads on concurrent access.
|
||||||
// NOTE: Current workflow execution is serial (execFileSync), so true concurrency doesn't occur.
|
// NOTE: Current workflow execution is serial (execFileSync), so true concurrency doesn't occur.
|
||||||
// This is a safety net for future parallel execution.
|
// This is a safety net for future parallel execution.
|
||||||
const tmpPath = join(dir, `.agent-sessions.${randomBytes(4).toString("hex")}.tmp`);
|
const tmpPath = join(dir, `.${agentName}-sessions.${randomBytes(4).toString("hex")}.tmp`);
|
||||||
await writeFile(tmpPath, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
|
await writeFile(tmpPath, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
|
||||||
await rename(tmpPath, path);
|
await rename(tmpPath, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read the cached session ID for a thread+role pair. */
|
/** Read the cached session ID for a thread+role pair. */
|
||||||
export async function getCachedSessionId(threadId: ThreadId, role: string): Promise<string | null> {
|
export async function getCachedSessionId(
|
||||||
const cache = await readCache();
|
agentName: string,
|
||||||
|
threadId: ThreadId,
|
||||||
|
role: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const cache = await readCache(agentName);
|
||||||
const sessionId = cache[cacheKey(threadId, role)];
|
const sessionId = cache[cacheKey(threadId, role)];
|
||||||
return sessionId ?? null;
|
return sessionId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Write the session ID for a thread+role pair into the cache. */
|
/** Write the session ID for a thread+role pair into the cache. */
|
||||||
export async function setCachedSessionId(
|
export async function setCachedSessionId(
|
||||||
|
agentName: string,
|
||||||
threadId: ThreadId,
|
threadId: ThreadId,
|
||||||
role: string,
|
role: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const cache = await readCache();
|
const cache = await readCache(agentName);
|
||||||
cache[cacheKey(threadId, role)] = sessionId;
|
cache[cacheKey(threadId, role)] = sessionId;
|
||||||
await writeCache(cache);
|
await writeCache(agentName, cache);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export type AgentContext = ModeratorContext & {
|
|||||||
*/
|
*/
|
||||||
outputFormatInstruction: string;
|
outputFormatInstruction: string;
|
||||||
/**
|
/**
|
||||||
* Edge prompt from the graph transition that led to this role (UWF_EDGE_PROMPT).
|
* Edge prompt from the graph transition that led to this role (--prompt CLI arg).
|
||||||
* Always the real moderator instruction for this step.
|
* Always the real moderator instruction for this step.
|
||||||
*/
|
*/
|
||||||
edgePrompt: string;
|
edgePrompt: string;
|
||||||
|
|||||||
@@ -31,8 +31,10 @@
|
|||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
|
"@vitest/ui": "^4.1.7",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^8.0.13"
|
"vite": "^8.0.13",
|
||||||
|
"vitest": "^4.1.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { Edge, Node } from "@xyflow/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { LayoutLR } from "../index.js";
|
||||||
|
|
||||||
|
function makeNode(id: string): Node {
|
||||||
|
return { id, type: "role", data: {}, position: { x: 0, y: 0 } } as Node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEdge(source: string, target: string): Edge {
|
||||||
|
return { id: `${source}-${target}`, source, target } as Edge;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("LayoutLR / assignLayers", () => {
|
||||||
|
it("1.1 Empty graph: start gets layer 0, end gets higher layer", () => {
|
||||||
|
const nodes = [makeNode("start"), makeNode("end")];
|
||||||
|
const result = LayoutLR(nodes, []);
|
||||||
|
const start = result.find((n) => n.id === "start");
|
||||||
|
const end = result.find((n) => n.id === "end");
|
||||||
|
// start has no position change necessarily, but positions should be assigned
|
||||||
|
expect(start).toBeDefined();
|
||||||
|
expect(end).toBeDefined();
|
||||||
|
// end should be to the right of start
|
||||||
|
expect((end?.position.x ?? 0) > (start?.position.x ?? 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("1.2 Linear chain: start → A → B → end — layers assigned in order", () => {
|
||||||
|
const nodes = [makeNode("start"), makeNode("A"), makeNode("B"), makeNode("end")];
|
||||||
|
const edges = [makeEdge("start", "A"), makeEdge("A", "B"), makeEdge("B", "end")];
|
||||||
|
const result = LayoutLR(nodes, edges);
|
||||||
|
const xOf = (id: string) => result.find((n) => n.id === id)?.position.x ?? 0;
|
||||||
|
expect(xOf("start") < xOf("A")).toBe(true);
|
||||||
|
expect(xOf("A") < xOf("B")).toBe(true);
|
||||||
|
expect(xOf("B") < xOf("end")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("1.3 Diamond: A and B share same layer", () => {
|
||||||
|
const nodes = [makeNode("start"), makeNode("A"), makeNode("B"), makeNode("C"), makeNode("end")];
|
||||||
|
const edges = [
|
||||||
|
makeEdge("start", "A"),
|
||||||
|
makeEdge("start", "B"),
|
||||||
|
makeEdge("A", "C"),
|
||||||
|
makeEdge("B", "C"),
|
||||||
|
makeEdge("C", "end"),
|
||||||
|
];
|
||||||
|
const result = LayoutLR(nodes, edges);
|
||||||
|
const xOf = (id: string) => result.find((n) => n.id === id)?.position.x ?? 0;
|
||||||
|
expect(xOf("A")).toBe(xOf("B")); // same layer
|
||||||
|
expect(xOf("A") < xOf("C")).toBe(true);
|
||||||
|
expect(xOf("C") < xOf("end")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("1.4 Isolated node placed in middle layer (not layer 0, not end layer)", () => {
|
||||||
|
const nodes = [makeNode("start"), makeNode("A"), makeNode("isolated"), makeNode("end")];
|
||||||
|
const edges = [makeEdge("start", "A"), makeEdge("A", "end")];
|
||||||
|
const result = LayoutLR(nodes, edges);
|
||||||
|
const xOf = (id: string) => result.find((n) => n.id === id)?.position.x ?? 0;
|
||||||
|
const xIsolated = xOf("isolated");
|
||||||
|
expect(xIsolated > xOf("start")).toBe(true);
|
||||||
|
expect(xIsolated < xOf("end")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("1.5 end node is always last (highest x)", () => {
|
||||||
|
const nodes = [makeNode("start"), makeNode("A"), makeNode("B"), makeNode("end")];
|
||||||
|
const edges = [makeEdge("start", "A"), makeEdge("A", "B"), makeEdge("B", "end")];
|
||||||
|
const result = LayoutLR(nodes, edges);
|
||||||
|
const endX = result.find((n) => n.id === "end")?.position.x ?? 0;
|
||||||
|
for (const node of result) {
|
||||||
|
if (node.id !== "end") {
|
||||||
|
expect(node.position.x < endX).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("1.6 start node is always first (x = 0 or smallest x)", () => {
|
||||||
|
const nodes = [makeNode("start"), makeNode("A"), makeNode("end")];
|
||||||
|
const edges = [makeEdge("start", "A"), makeEdge("A", "end")];
|
||||||
|
const result = LayoutLR(nodes, edges);
|
||||||
|
const startX = result.find((n) => n.id === "start")?.position.x ?? 0;
|
||||||
|
for (const node of result) {
|
||||||
|
expect(node.position.x >= startX).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -43,6 +43,65 @@ function buildGraph(nodes: Node[], edges: Edge[]) {
|
|||||||
return { outgoing, incoming, inDegree };
|
return { outgoing, incoming, inDegree };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function processTarget(
|
||||||
|
target: string,
|
||||||
|
newLayer: number,
|
||||||
|
layers: Map<string, number>,
|
||||||
|
inDegree: Map<string, number>,
|
||||||
|
queue: string[],
|
||||||
|
): void {
|
||||||
|
const existingLayer = layers.get(target);
|
||||||
|
if (existingLayer === undefined) {
|
||||||
|
layers.set(target, newLayer);
|
||||||
|
inDegree.set(target, (inDegree.get(target) ?? 1) - 1);
|
||||||
|
if (inDegree.get(target) === 0) queue.push(target);
|
||||||
|
} else {
|
||||||
|
layers.set(target, Math.max(existingLayer, newLayer));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BFS 分层(排除 end 节点,稍后单独处理)
|
||||||
|
*/
|
||||||
|
function bfsLayers(
|
||||||
|
outgoing: Map<string, string[]>,
|
||||||
|
inDegree: Map<string, number>,
|
||||||
|
layers: Map<string, number>,
|
||||||
|
): void {
|
||||||
|
const queue: string[] = ["start"];
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const current = queue.shift() ?? "";
|
||||||
|
const currentLayer = layers.get(current) ?? 0;
|
||||||
|
for (const target of outgoing.get(current) ?? []) {
|
||||||
|
if (target === "end") continue;
|
||||||
|
processTarget(target, currentLayer + 1, layers, inDegree, queue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理孤立节点(没有被分配层级的非 start/end 节点),放在中间层
|
||||||
|
*/
|
||||||
|
function placeIsolatedNodes(nodes: Node[], layers: Map<string, number>, maxLayer: number): void {
|
||||||
|
const middleLayer = Math.max(1, Math.floor((maxLayer + 1) / 2));
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.id !== "start" && node.id !== "end" && !layers.has(node.id)) {
|
||||||
|
layers.set(node.id, middleLayer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算最大层级(排除 end 节点)
|
||||||
|
*/
|
||||||
|
function maxLayerExcludingEnd(layers: Map<string, number>): number {
|
||||||
|
let max = 0;
|
||||||
|
for (const [id, layer] of layers) {
|
||||||
|
if (id !== "end") max = Math.max(max, layer);
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用拓扑排序将节点分层
|
* 使用拓扑排序将节点分层
|
||||||
* - 'start' 节点固定在第 0 层
|
* - 'start' 节点固定在第 0 层
|
||||||
@@ -52,62 +111,15 @@ function buildGraph(nodes: Node[], edges: Edge[]) {
|
|||||||
function assignLayers(nodes: Node[], edges: Edge[]): Map<string, number> {
|
function assignLayers(nodes: Node[], edges: Edge[]): Map<string, number> {
|
||||||
const { outgoing, inDegree } = buildGraph(nodes, edges);
|
const { outgoing, inDegree } = buildGraph(nodes, edges);
|
||||||
const layers = new Map<string, number>();
|
const layers = new Map<string, number>();
|
||||||
const queue: string[] = [];
|
|
||||||
|
|
||||||
// 1. start 节点固定在第 0 层
|
|
||||||
layers.set("start", 0);
|
layers.set("start", 0);
|
||||||
queue.push("start");
|
bfsLayers(outgoing, inDegree, layers);
|
||||||
|
|
||||||
// 2. BFS 分层(排除 end 节点,稍后单独处理)
|
const afterBfsMax = maxLayerExcludingEnd(layers);
|
||||||
while (queue.length > 0) {
|
placeIsolatedNodes(nodes, layers, afterBfsMax);
|
||||||
const current = queue.shift() ?? "";
|
|
||||||
const currentLayer = layers.get(current) ?? 0;
|
|
||||||
|
|
||||||
for (const target of outgoing.get(current) ?? []) {
|
const finalMax = maxLayerExcludingEnd(layers);
|
||||||
// 跳过 end 节点,稍后处理
|
layers.set("end", finalMax + 1);
|
||||||
if (target === "end") continue;
|
|
||||||
|
|
||||||
const newLayer = currentLayer + 1;
|
|
||||||
const existingLayer = layers.get(target);
|
|
||||||
|
|
||||||
if (existingLayer === undefined) {
|
|
||||||
layers.set(target, newLayer);
|
|
||||||
inDegree.set(target, (inDegree.get(target) ?? 1) - 1);
|
|
||||||
if (inDegree.get(target) === 0) {
|
|
||||||
queue.push(target);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 如果已有层级,取更大的值(确保所有前驱都在前面)
|
|
||||||
layers.set(target, Math.max(existingLayer, newLayer));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 找到当前最大层级
|
|
||||||
let maxLayer = 0;
|
|
||||||
for (const layer of layers.values()) {
|
|
||||||
maxLayer = Math.max(maxLayer, layer);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 处理孤立节点(没有被分配层级的非 start/end 节点)
|
|
||||||
// 把它们放在中间层
|
|
||||||
const middleLayer = Math.max(1, Math.floor((maxLayer + 1) / 2));
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (node.id !== "start" && node.id !== "end" && !layers.has(node.id)) {
|
|
||||||
layers.set(node.id, middleLayer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 重新计算最大层级(可能因为孤立节点而变化)
|
|
||||||
maxLayer = 0;
|
|
||||||
for (const [id, layer] of layers) {
|
|
||||||
if (id !== "end") {
|
|
||||||
maxLayer = Math.max(maxLayer, layer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. end 节点固定在最后一层
|
|
||||||
layers.set("end", maxLayer + 1);
|
|
||||||
|
|
||||||
return layers;
|
return layers;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,23 +30,24 @@ export const handlers = define.memoize((use, model) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isProtectedNode(node: AnyWorkNode): boolean {
|
||||||
|
return node.type === "start" || node.type === "end";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFirstConditionalSibling(
|
||||||
|
edge: { id: string; source: string; type: string | null },
|
||||||
|
allEdges: { id: string; source: string; type: string | null }[],
|
||||||
|
): boolean {
|
||||||
|
if (edge.type !== "conditional") return false;
|
||||||
|
const siblings = allEdges.filter((e) => e.source === edge.source && e.type === "conditional");
|
||||||
|
return siblings.length >= 2 && siblings[0].id === edge.id;
|
||||||
|
}
|
||||||
|
|
||||||
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes, edges }) => {
|
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes, edges }) => {
|
||||||
for (const node of nodes) {
|
if (nodes.some(isProtectedNode)) return false;
|
||||||
if (node.type === "start" || node.type === "end") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (edges.length > 0) {
|
if (edges.length > 0) {
|
||||||
const allEdges = use(edgesModel)[0];
|
const allEdges = use(edgesModel)[0];
|
||||||
for (const edge of edges) {
|
if (edges.some((e) => isFirstConditionalSibling(e, allEdges))) return false;
|
||||||
if (edge.type !== "conditional") continue;
|
|
||||||
const siblings = allEdges.filter(
|
|
||||||
(e) => e.source === edge.source && e.type === "conditional",
|
|
||||||
);
|
|
||||||
if (siblings.length >= 2 && siblings[0].id === edge.id) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
model.startTransaction();
|
model.startTransaction();
|
||||||
return true;
|
return true;
|
||||||
@@ -96,25 +97,28 @@ export const handlers = define.memoize((use, model) => {
|
|||||||
use(editNodeViewModel)[1].cancel();
|
use(editNodeViewModel)[1].cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleEscape() {
|
||||||
|
const [addView, addViewActions] = use(addNodeViewModel);
|
||||||
|
const [editView, editViewActions] = use(editNodeViewModel);
|
||||||
|
if (addView) addViewActions.cancel();
|
||||||
|
if (editView) editViewActions.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUndoRedo(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||||
|
if (event.code === "KeyZ" && (event.ctrlKey || event.metaKey)) {
|
||||||
|
if (event.shiftKey) model.redo();
|
||||||
|
else model.undo();
|
||||||
|
} else if (event.code === "KeyY" && (event.ctrlKey || event.metaKey)) {
|
||||||
|
model.redo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||||
if (event.code === "Escape") {
|
if (event.code === "Escape") {
|
||||||
const [addView, addViewActions] = use(addNodeViewModel);
|
handleEscape();
|
||||||
const [editView, editViewActions] = use(editNodeViewModel);
|
|
||||||
if (addView) addViewActions.cancel();
|
|
||||||
if (editView) editViewActions.cancel();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
handleUndoRedo(event);
|
||||||
if (event.code === "KeyZ") {
|
|
||||||
if (event.ctrlKey || event.metaKey) {
|
|
||||||
if (event.shiftKey) model.redo();
|
|
||||||
else model.undo();
|
|
||||||
}
|
|
||||||
} else if (event.code === "KeyY") {
|
|
||||||
if (event.ctrlKey || event.metaKey) {
|
|
||||||
model.redo();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadSteps(steps: WorkFlowSteps) {
|
function loadSteps(steps: WorkFlowSteps) {
|
||||||
|
|||||||
@@ -10,16 +10,15 @@ import {
|
|||||||
import { Input } from "../../components/ui/input.tsx";
|
import { Input } from "../../components/ui/input.tsx";
|
||||||
import { Label } from "../../components/ui/label.tsx";
|
import { Label } from "../../components/ui/label.tsx";
|
||||||
import { Textarea } from "../../components/ui/textarea.tsx";
|
import { Textarea } from "../../components/ui/textarea.tsx";
|
||||||
import { type AddNodeState, addNodeViewModel } from "../model/index.ts";
|
import { addNodeViewModel } from "../model/index.ts";
|
||||||
import type { RoleNodeData } from "../type.ts";
|
import type { RoleNodeData } from "../type.ts";
|
||||||
|
|
||||||
type FormProps = {
|
type FormProps = {
|
||||||
state: AddNodeState;
|
|
||||||
onSubmit: (params: { data: RoleNodeData }) => void;
|
onSubmit: (params: { data: RoleNodeData }) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function Form({ state, onSubmit, onCancel }: FormProps): ReactNode {
|
function Form({ onSubmit, onCancel }: FormProps): ReactNode {
|
||||||
const [name, setName] = useState("新角色");
|
const [name, setName] = useState("新角色");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [identity, setIdentity] = useState("");
|
const [identity, setIdentity] = useState("");
|
||||||
@@ -137,7 +136,7 @@ export function AddNodeDialog(): ReactNode {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent showCloseButton={false} className="sm:max-w-md">
|
<DialogContent showCloseButton={false} className="sm:max-w-md">
|
||||||
{state && <Form state={state} onSubmit={commit} onCancel={cancel} />}
|
{state && <Form onSubmit={commit} onCancel={cancel} />}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { transIn } from "../trans-in.js";
|
||||||
|
import type { WorkFlowStep } from "../type.js";
|
||||||
|
|
||||||
|
function makeStep(name: string, transitions: WorkFlowStep["transitions"]): WorkFlowStep {
|
||||||
|
return {
|
||||||
|
role: {
|
||||||
|
name,
|
||||||
|
description: "",
|
||||||
|
identity: "",
|
||||||
|
prepare: "",
|
||||||
|
execute: "",
|
||||||
|
report: "",
|
||||||
|
},
|
||||||
|
transitions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("transIn", () => {
|
||||||
|
it("4.1 Empty steps → start + end nodes, no edges", () => {
|
||||||
|
const { nodes, edges } = transIn([]);
|
||||||
|
expect(nodes).toHaveLength(2);
|
||||||
|
expect(nodes.find((n) => n.id === "start")).toBeDefined();
|
||||||
|
expect(nodes.find((n) => n.id === "end")).toBeDefined();
|
||||||
|
expect(edges).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("4.2 Single step with no END transition → start→role edge exists", () => {
|
||||||
|
const steps = [makeStep("A", [])];
|
||||||
|
const { nodes, edges } = transIn(steps);
|
||||||
|
expect(nodes).toHaveLength(3); // start, end, role-A
|
||||||
|
const startEdge = edges.find((e) => e.source === "start");
|
||||||
|
expect(startEdge).toBeDefined();
|
||||||
|
const roleNode = nodes.find((n) => n.type === "role");
|
||||||
|
expect(startEdge?.target).toBe(roleNode?.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("4.3 Single step with END transition → edge to end node exists", () => {
|
||||||
|
const steps = [makeStep("A", [{ condition: null, target: "END" }])];
|
||||||
|
const { edges } = transIn(steps);
|
||||||
|
const endEdge = edges.find((e) => e.target === "end");
|
||||||
|
expect(endEdge).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("4.4 Two steps with default transitions chain", () => {
|
||||||
|
const steps = [
|
||||||
|
makeStep("A", [{ condition: null, target: "B" }]),
|
||||||
|
makeStep("B", [{ condition: null, target: "END" }]),
|
||||||
|
];
|
||||||
|
const { edges } = transIn(steps);
|
||||||
|
// Should have start→A, A→B, B→end
|
||||||
|
expect(edges.find((e) => e.source === "start")).toBeDefined();
|
||||||
|
const nodeAId = edges.find((e) => e.source === "start")?.target;
|
||||||
|
expect(edges.find((e) => e.source === nodeAId && e.target !== "end")).toBeDefined();
|
||||||
|
expect(edges.find((e) => e.target === "end")).toBeDefined();
|
||||||
|
// No conditional edges
|
||||||
|
expect(edges.every((e) => e.type !== "conditional")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("4.5 Step with multiple transitions → conditional edges", () => {
|
||||||
|
const steps = [
|
||||||
|
makeStep("A", [
|
||||||
|
{ condition: null, target: "B" },
|
||||||
|
{ condition: "x>0", target: "C" },
|
||||||
|
]),
|
||||||
|
makeStep("B", []),
|
||||||
|
makeStep("C", []),
|
||||||
|
];
|
||||||
|
const { edges } = transIn(steps);
|
||||||
|
const nodeAId = edges.find((e) => e.source === "start")?.target;
|
||||||
|
const outEdges = edges.filter((e) => e.source === nodeAId);
|
||||||
|
expect(outEdges.every((e) => e.type === "conditional")).toBe(true);
|
||||||
|
// else-branch has empty condition
|
||||||
|
const elseEdge = outEdges.find(
|
||||||
|
(e) => (e as { data?: { condition?: string } }).data?.condition === "",
|
||||||
|
);
|
||||||
|
expect(elseEdge).toBeDefined();
|
||||||
|
// if-branch has condition
|
||||||
|
const ifEdge = outEdges.find(
|
||||||
|
(e) => (e as { data?: { condition?: string } }).data?.condition === "x>0",
|
||||||
|
);
|
||||||
|
expect(ifEdge).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("4.6 With 1 incoming edge: targetHandle = 'input'; with 2: first gets 'input'", () => {
|
||||||
|
const steps = [
|
||||||
|
makeStep("A", [{ condition: null, target: "END" }]),
|
||||||
|
makeStep("B", [{ condition: null, target: "END" }]),
|
||||||
|
];
|
||||||
|
const { edges } = transIn(steps);
|
||||||
|
// start→A and start→B; end has 2 incoming edges
|
||||||
|
const incomingToEnd = edges.filter((e) => e.target === "end");
|
||||||
|
expect(incomingToEnd[0].targetHandle).toBe("input");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("4.7 Same role name maps to same node id across steps", () => {
|
||||||
|
const steps = [
|
||||||
|
makeStep("A", [{ condition: null, target: "B" }]),
|
||||||
|
makeStep("B", [{ condition: null, target: "A" }]),
|
||||||
|
];
|
||||||
|
const { edges } = transIn(steps);
|
||||||
|
const aId = edges.find((e) => e.source === "start")?.target;
|
||||||
|
// B→A edge target should be same node as start→A edge target
|
||||||
|
const bToAEdge = edges.find(
|
||||||
|
(e) => e.source !== "start" && e.target === aId && e.target !== "end",
|
||||||
|
);
|
||||||
|
expect(bToAEdge).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { AnyWorkEdge, AnyWorkNode } from "../../type.js";
|
||||||
|
import { validate } from "../validate.js";
|
||||||
|
|
||||||
|
function roleNode(id: string): AnyWorkNode {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: "role",
|
||||||
|
data: { name: id, description: "", identity: "", prepare: "", execute: "", report: "" },
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
} as AnyWorkNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNode(): AnyWorkNode {
|
||||||
|
return {
|
||||||
|
id: "start",
|
||||||
|
type: "start",
|
||||||
|
data: { label: "Start" },
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
} as AnyWorkNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function endNode(): AnyWorkNode {
|
||||||
|
return {
|
||||||
|
id: "end",
|
||||||
|
type: "end",
|
||||||
|
data: { label: "End" },
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
} as AnyWorkNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultEdge(source: string, target: string): AnyWorkEdge {
|
||||||
|
return { id: `${source}-${target}`, source, target, animated: true } as AnyWorkEdge;
|
||||||
|
}
|
||||||
|
|
||||||
|
function conditionalEdge(source: string, target: string, condition: string): AnyWorkEdge {
|
||||||
|
return {
|
||||||
|
id: `${source}-${target}-cond`,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
type: "conditional" as const,
|
||||||
|
data: { condition },
|
||||||
|
animated: true,
|
||||||
|
} as AnyWorkEdge;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: build a minimal valid graph with 2 role nodes for validateRoleNodes tests
|
||||||
|
function baseNodes(...roles: AnyWorkNode[]): AnyWorkNode[] {
|
||||||
|
return [startNode(), ...roles, endNode()];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("validateRoleNodes (via validate)", () => {
|
||||||
|
it("5.1 Role node with no incoming edge → error about missing input", () => {
|
||||||
|
const n1 = roleNode("n1");
|
||||||
|
const n2 = roleNode("n2");
|
||||||
|
const nodes = baseNodes(n1, n2);
|
||||||
|
// n1 has no incoming, n2 has incoming+outgoing
|
||||||
|
const edges = [defaultEdge("start", "n2"), defaultEdge("n1", "end"), defaultEdge("n2", "end")];
|
||||||
|
const result = validate(nodes, edges);
|
||||||
|
const nodeErrors = result.errors.filter((e) => e.nodeId === "n1");
|
||||||
|
expect(nodeErrors.some((e) => e.message.includes("缺少输入连接"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("5.2 Role node with no outgoing edge → error about missing output", () => {
|
||||||
|
const n1 = roleNode("n1");
|
||||||
|
const n2 = roleNode("n2");
|
||||||
|
const nodes = baseNodes(n1, n2);
|
||||||
|
const edges = [
|
||||||
|
defaultEdge("start", "n1"),
|
||||||
|
defaultEdge("start", "n2"),
|
||||||
|
defaultEdge("n2", "end"),
|
||||||
|
// n1 has no outgoing
|
||||||
|
];
|
||||||
|
const result = validate(nodes, edges);
|
||||||
|
const nodeErrors = result.errors.filter((e) => e.nodeId === "n1");
|
||||||
|
expect(nodeErrors.some((e) => e.message.includes("缺少输出连接"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("5.3 Empty condition on non-first conditional edge → error", () => {
|
||||||
|
const n1 = roleNode("n1");
|
||||||
|
const n2 = roleNode("n2");
|
||||||
|
const n3 = roleNode("n3");
|
||||||
|
const nodes = baseNodes(n1, n2, n3);
|
||||||
|
const edges = [
|
||||||
|
defaultEdge("start", "n1"),
|
||||||
|
conditionalEdge("n1", "n2", ""), // else-branch (index 0) - exempt
|
||||||
|
conditionalEdge("n1", "n3", ""), // if-branch (index 1) - empty condition → error
|
||||||
|
defaultEdge("n2", "end"),
|
||||||
|
defaultEdge("n3", "end"),
|
||||||
|
];
|
||||||
|
const result = validate(nodes, edges);
|
||||||
|
expect(result.errors.some((e) => e.message.includes("条件表达式不能为空"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("5.4 Mix of conditional and non-conditional outgoing → error", () => {
|
||||||
|
const n1 = roleNode("n1");
|
||||||
|
const n2 = roleNode("n2");
|
||||||
|
const n3 = roleNode("n3");
|
||||||
|
const nodes = baseNodes(n1, n2, n3);
|
||||||
|
const edges = [
|
||||||
|
defaultEdge("start", "n1"),
|
||||||
|
conditionalEdge("n1", "n2", "x>0"),
|
||||||
|
defaultEdge("n1", "n3"), // mix → error
|
||||||
|
defaultEdge("n2", "end"),
|
||||||
|
defaultEdge("n3", "end"),
|
||||||
|
];
|
||||||
|
const result = validate(nodes, edges);
|
||||||
|
expect(result.errors.some((e) => e.message.includes("所有出边必须附带条件"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("5.5 Valid role node (1 in, 1 out default) → no errors for that node", () => {
|
||||||
|
const n1 = roleNode("n1");
|
||||||
|
const n2 = roleNode("n2");
|
||||||
|
const nodes = baseNodes(n1, n2);
|
||||||
|
const edges = [defaultEdge("start", "n1"), defaultEdge("n1", "n2"), defaultEdge("n2", "end")];
|
||||||
|
const result = validate(nodes, edges);
|
||||||
|
const roleErrors = result.errors.filter((e) => e.nodeId === "n1" || e.nodeId === "n2");
|
||||||
|
expect(roleErrors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("5.6 Valid role node (1 in, 2 conditional out with conditions) → no errors", () => {
|
||||||
|
const n1 = roleNode("n1");
|
||||||
|
const n2 = roleNode("n2");
|
||||||
|
const n3 = roleNode("n3");
|
||||||
|
const nodes = baseNodes(n1, n2, n3);
|
||||||
|
const edges = [
|
||||||
|
defaultEdge("start", "n1"),
|
||||||
|
conditionalEdge("n1", "n2", ""), // else-branch
|
||||||
|
conditionalEdge("n1", "n3", "x>0"), // if-branch
|
||||||
|
defaultEdge("n2", "end"),
|
||||||
|
defaultEdge("n3", "end"),
|
||||||
|
];
|
||||||
|
const result = validate(nodes, edges);
|
||||||
|
const n1Errors = result.errors.filter((e) => e.nodeId === "n1");
|
||||||
|
expect(n1Errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,109 @@ function assignHandles(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildNodeMap(
|
||||||
|
steps: WorkFlowStep[],
|
||||||
|
nodes: AnyWorkNode[],
|
||||||
|
): { nameToId: Map<string, string>; idToOrder: Map<string, number> } {
|
||||||
|
const nameToId = new Map<string, string>();
|
||||||
|
const idToOrder = new Map<string, number>();
|
||||||
|
nameToId.set("END", "end");
|
||||||
|
idToOrder.set("start", -1);
|
||||||
|
idToOrder.set("end", steps.length);
|
||||||
|
for (let si = 0; si < steps.length; si++) {
|
||||||
|
const step = steps[si];
|
||||||
|
const nodeId = `n${uuid()}`;
|
||||||
|
nameToId.set(step.role.name, nodeId);
|
||||||
|
idToOrder.set(nodeId, si);
|
||||||
|
nodes.push({ id: nodeId, type: "role", data: { ...step.role }, position: { x: 0, y: 0 } });
|
||||||
|
}
|
||||||
|
return { nameToId, idToOrder };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortTransitions(step: WorkFlowStep): WorkFlowStep["transitions"] {
|
||||||
|
if (step.transitions.length <= 1) return step.transitions;
|
||||||
|
return [...step.transitions].sort((a, b) => {
|
||||||
|
if (a.condition === null && b.condition !== null) return -1;
|
||||||
|
if (a.condition !== null && b.condition === null) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStepEdges(
|
||||||
|
sourceId: string,
|
||||||
|
step: WorkFlowStep,
|
||||||
|
nameToId: Map<string, string>,
|
||||||
|
): { elseEdges: AnyWorkEdge[]; ifEdges: AnyWorkEdge[] } {
|
||||||
|
const hasMultiple = step.transitions.length > 1;
|
||||||
|
const sorted = sortTransitions(step);
|
||||||
|
const elseEdges: AnyWorkEdge[] = [];
|
||||||
|
const ifEdges: AnyWorkEdge[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
|
const t = sorted[i];
|
||||||
|
const targetId = nameToId.get(t.target);
|
||||||
|
if (!targetId) continue;
|
||||||
|
const edgeId = `e-${sourceId}-${targetId}-${i}`;
|
||||||
|
if (hasMultiple || t.condition !== null) {
|
||||||
|
const edge: ConditionalEdge = {
|
||||||
|
id: edgeId,
|
||||||
|
source: sourceId,
|
||||||
|
target: targetId,
|
||||||
|
sourceHandle: "output",
|
||||||
|
targetHandle: "input",
|
||||||
|
type: "conditional",
|
||||||
|
data: { condition: t.condition ?? "" },
|
||||||
|
animated: true,
|
||||||
|
};
|
||||||
|
if (hasMultiple && i === 0) elseEdges.push(edge);
|
||||||
|
else ifEdges.push(edge);
|
||||||
|
} else {
|
||||||
|
elseEdges.push({
|
||||||
|
id: edgeId,
|
||||||
|
source: sourceId,
|
||||||
|
target: targetId,
|
||||||
|
sourceHandle: "output",
|
||||||
|
targetHandle: "input",
|
||||||
|
animated: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { elseEdges, ifEdges };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushStepEdges(
|
||||||
|
edges: AnyWorkEdge[],
|
||||||
|
elseEdges: AnyWorkEdge[],
|
||||||
|
ifEdges: AnyWorkEdge[],
|
||||||
|
idToOrder: Map<string, number>,
|
||||||
|
): void {
|
||||||
|
for (const e of elseEdges) edges.push({ ...e, sourceHandle: "output" });
|
||||||
|
if (ifEdges.length > 0) {
|
||||||
|
const ifHandles = ["output-top", "output-bottom"] as const;
|
||||||
|
const sorted = [...ifEdges].sort(
|
||||||
|
(a, b) => (idToOrder.get(b.target) ?? 0) - (idToOrder.get(a.target) ?? 0),
|
||||||
|
);
|
||||||
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
|
edges.push({ ...sorted[i], sourceHandle: ifHandles[i % ifHandles.length] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignTargetHandles(edges: AnyWorkEdge[], idToOrder: Map<string, number>): void {
|
||||||
|
const incomingByTarget = new Map<string, number[]>();
|
||||||
|
for (let i = 0; i < edges.length; i++) {
|
||||||
|
const target = edges[i].target;
|
||||||
|
if (!incomingByTarget.has(target)) incomingByTarget.set(target, []);
|
||||||
|
incomingByTarget.get(target)?.push(i);
|
||||||
|
}
|
||||||
|
for (const indices of incomingByTarget.values()) {
|
||||||
|
indices.sort(
|
||||||
|
(a, b) => (idToOrder.get(edges[a].source) ?? 0) - (idToOrder.get(edges[b].source) ?? 0),
|
||||||
|
);
|
||||||
|
assignHandles(indices, edges, IN_HANDLES, "targetHandle");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function transIn(steps: WorkFlowStep[]): Result {
|
export function transIn(steps: WorkFlowStep[]): Result {
|
||||||
const startNode: AnyWorkNode = {
|
const startNode: AnyWorkNode = {
|
||||||
id: "start",
|
id: "start",
|
||||||
@@ -42,30 +145,12 @@ export function transIn(steps: WorkFlowStep[]): Result {
|
|||||||
position: { x: 250, y: 0 },
|
position: { x: 250, y: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
if (steps.length === 0) {
|
if (steps.length === 0) return { nodes: [startNode, endNode], edges: [] };
|
||||||
return { nodes: [startNode, endNode], edges: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodes: AnyWorkNode[] = [startNode, endNode];
|
const nodes: AnyWorkNode[] = [startNode, endNode];
|
||||||
const edges: AnyWorkEdge[] = [];
|
const edges: AnyWorkEdge[] = [];
|
||||||
const nameToId = new Map<string, string>();
|
|
||||||
const idToOrder = new Map<string, number>();
|
|
||||||
nameToId.set("END", "end");
|
|
||||||
idToOrder.set("start", -1);
|
|
||||||
idToOrder.set("end", steps.length);
|
|
||||||
|
|
||||||
for (let si = 0; si < steps.length; si++) {
|
const { nameToId, idToOrder } = buildNodeMap(steps, nodes);
|
||||||
const step = steps[si];
|
|
||||||
const nodeId = `n${uuid()}`;
|
|
||||||
nameToId.set(step.role.name, nodeId);
|
|
||||||
idToOrder.set(nodeId, si);
|
|
||||||
nodes.push({
|
|
||||||
id: nodeId,
|
|
||||||
type: "role",
|
|
||||||
data: { ...step.role },
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstStepId = nameToId.get(steps[0].role.name) ?? "";
|
const firstStepId = nameToId.get(steps[0].role.name) ?? "";
|
||||||
edges.push({
|
edges.push({
|
||||||
@@ -79,88 +164,11 @@ export function transIn(steps: WorkFlowStep[]): Result {
|
|||||||
|
|
||||||
for (const step of steps) {
|
for (const step of steps) {
|
||||||
const sourceId = nameToId.get(step.role.name) ?? "";
|
const sourceId = nameToId.get(step.role.name) ?? "";
|
||||||
const _sourceOrder = idToOrder.get(sourceId) ?? 0;
|
const { elseEdges, ifEdges } = buildStepEdges(sourceId, step, nameToId);
|
||||||
const hasMultipleTransitions = step.transitions.length > 1;
|
pushStepEdges(edges, elseEdges, ifEdges, idToOrder);
|
||||||
|
|
||||||
const sorted = hasMultipleTransitions
|
|
||||||
? [...step.transitions].sort((a, b) => {
|
|
||||||
if (a.condition === null && b.condition !== null) return -1;
|
|
||||||
if (a.condition !== null && b.condition === null) return 1;
|
|
||||||
return 0;
|
|
||||||
})
|
|
||||||
: step.transitions;
|
|
||||||
|
|
||||||
const elseEdges: AnyWorkEdge[] = [];
|
|
||||||
const ifEdges: AnyWorkEdge[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < sorted.length; i++) {
|
|
||||||
const t = sorted[i];
|
|
||||||
const targetId = nameToId.get(t.target);
|
|
||||||
if (!targetId) continue;
|
|
||||||
|
|
||||||
const edgeId = `e-${sourceId}-${targetId}-${i}`;
|
|
||||||
|
|
||||||
if (hasMultipleTransitions || t.condition !== null) {
|
|
||||||
const edge: ConditionalEdge = {
|
|
||||||
id: edgeId,
|
|
||||||
source: sourceId,
|
|
||||||
target: targetId,
|
|
||||||
sourceHandle: "output",
|
|
||||||
targetHandle: "input",
|
|
||||||
type: "conditional",
|
|
||||||
data: { condition: t.condition ?? "" },
|
|
||||||
animated: true,
|
|
||||||
};
|
|
||||||
if (hasMultipleTransitions && i === 0) {
|
|
||||||
elseEdges.push(edge);
|
|
||||||
} else {
|
|
||||||
ifEdges.push(edge);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
elseEdges.push({
|
|
||||||
id: edgeId,
|
|
||||||
source: sourceId,
|
|
||||||
target: targetId,
|
|
||||||
sourceHandle: "output",
|
|
||||||
targetHandle: "input",
|
|
||||||
animated: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// out: else → output (right); if → sort by target order desc (rightmost first), then top/bottom
|
|
||||||
for (const e of elseEdges) {
|
|
||||||
edges.push({ ...e, sourceHandle: "output" });
|
|
||||||
}
|
|
||||||
if (ifEdges.length > 0) {
|
|
||||||
const sortedIf = [...ifEdges].sort((a, b) => {
|
|
||||||
const oa = idToOrder.get(a.target) ?? 0;
|
|
||||||
const ob = idToOrder.get(b.target) ?? 0;
|
|
||||||
return ob - oa;
|
|
||||||
});
|
|
||||||
const ifHandles = ["output-top", "output-bottom"] as const;
|
|
||||||
for (let i = 0; i < sortedIf.length; i++) {
|
|
||||||
edges.push({ ...sortedIf[i], sourceHandle: ifHandles[i % ifHandles.length] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// in: group by target, sort by source order asc (leftmost first), assign input > input-top > input-bottom
|
assignTargetHandles(edges, idToOrder);
|
||||||
const incomingByTarget = new Map<string, number[]>();
|
|
||||||
for (let i = 0; i < edges.length; i++) {
|
|
||||||
const target = edges[i].target;
|
|
||||||
if (!incomingByTarget.has(target)) incomingByTarget.set(target, []);
|
|
||||||
incomingByTarget.get(target)?.push(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const indices of incomingByTarget.values()) {
|
|
||||||
indices.sort((a, b) => {
|
|
||||||
const oa = idToOrder.get(edges[a].source) ?? 0;
|
|
||||||
const ob = idToOrder.get(edges[b].source) ?? 0;
|
|
||||||
return oa - ob;
|
|
||||||
});
|
|
||||||
assignHandles(indices, edges, IN_HANDLES, "targetHandle");
|
|
||||||
}
|
|
||||||
|
|
||||||
return { nodes, edges };
|
return { nodes, edges };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,36 @@ function validateEndNode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasEmptyConditionOnIfEdge(conditionalEdges: AnyWorkEdge[]): boolean {
|
||||||
|
return conditionalEdges.slice(1).some((edge) => {
|
||||||
|
const cond = (edge as ConditionalEdge).data?.condition?.trim();
|
||||||
|
return !cond;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRoleNodeEdges(
|
||||||
|
node: AnyWorkNode,
|
||||||
|
outEdges: AnyWorkEdge[],
|
||||||
|
inEdges: AnyWorkEdge[],
|
||||||
|
errors: ValidationError[],
|
||||||
|
): void {
|
||||||
|
if (inEdges.length === 0) {
|
||||||
|
errors.push({ nodeId: node.id, message: "角色节点缺少输入连接" });
|
||||||
|
}
|
||||||
|
if (outEdges.length === 0) {
|
||||||
|
errors.push({ nodeId: node.id, message: "角色节点缺少输出连接" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (outEdges.length <= 1) return;
|
||||||
|
|
||||||
|
const conditionalEdges = outEdges.filter((e) => e.type === "conditional");
|
||||||
|
if (conditionalEdges.length !== outEdges.length) {
|
||||||
|
errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带条件" });
|
||||||
|
} else if (hasEmptyConditionOnIfEdge(conditionalEdges)) {
|
||||||
|
errors.push({ nodeId: node.id, message: "条件边的条件表达式不能为空" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function validateRoleNodes(
|
function validateRoleNodes(
|
||||||
roleNodes: AnyWorkNode[],
|
roleNodes: AnyWorkNode[],
|
||||||
outgoing: Map<string, AnyWorkEdge[]>,
|
outgoing: Map<string, AnyWorkEdge[]>,
|
||||||
@@ -98,31 +128,7 @@ function validateRoleNodes(
|
|||||||
errors: ValidationError[],
|
errors: ValidationError[],
|
||||||
): void {
|
): void {
|
||||||
for (const node of roleNodes) {
|
for (const node of roleNodes) {
|
||||||
const inEdges = incoming.get(node.id) ?? [];
|
validateRoleNodeEdges(node, outgoing.get(node.id) ?? [], incoming.get(node.id) ?? [], errors);
|
||||||
const outEdges = outgoing.get(node.id) ?? [];
|
|
||||||
|
|
||||||
if (inEdges.length === 0) {
|
|
||||||
errors.push({ nodeId: node.id, message: "角色节点缺少输入连接" });
|
|
||||||
}
|
|
||||||
if (outEdges.length === 0) {
|
|
||||||
errors.push({ nodeId: node.id, message: "角色节点缺少输出连接" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outEdges.length > 1) {
|
|
||||||
const conditionalEdges = outEdges.filter((e) => e.type === "conditional");
|
|
||||||
if (conditionalEdges.length !== outEdges.length) {
|
|
||||||
errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带条件" });
|
|
||||||
} else {
|
|
||||||
const ifEdges = conditionalEdges.slice(1);
|
|
||||||
for (const edge of ifEdges) {
|
|
||||||
const condEdge = edge as ConditionalEdge;
|
|
||||||
if (!condEdge.data?.condition?.trim()) {
|
|
||||||
errors.push({ nodeId: node.id, message: "条件边的条件表达式不能为空" });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -92,7 +92,7 @@ type StepNodePayload = StepRecord & {
|
|||||||
### Moderator context
|
### Moderator context
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
type StepContext = Omit<StepRecord, "output"> & { output: unknown };
|
type StepContext = Omit<StepRecord, "output"> & { output: unknown; content: string | null };
|
||||||
|
|
||||||
type ModeratorContext = {
|
type ModeratorContext = {
|
||||||
start: StartNodePayload;
|
start: StartNodePayload;
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export type {
|
|||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
RoleDefinition,
|
RoleDefinition,
|
||||||
RoleName,
|
RoleName,
|
||||||
|
RunningThreadItem,
|
||||||
|
RunningThreadsOutput,
|
||||||
Scenario,
|
Scenario,
|
||||||
StartEntry,
|
StartEntry,
|
||||||
StartNodePayload,
|
StartNodePayload,
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export type StepNodePayload = StepRecord & {
|
|||||||
/** JSONata 上下文中的 step — output 被展开 */
|
/** JSONata 上下文中的 step — output 被展开 */
|
||||||
export type StepContext = Omit<StepRecord, "output"> & {
|
export type StepContext = Omit<StepRecord, "output"> & {
|
||||||
output: unknown;
|
output: unknown;
|
||||||
|
content: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ModeratorContext = {
|
export type ModeratorContext = {
|
||||||
@@ -84,6 +85,7 @@ export type StepOutput = {
|
|||||||
thread: ThreadId;
|
thread: ThreadId;
|
||||||
head: CasRef;
|
head: CasRef;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
|
background: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** uwf thread steps — single step entry */
|
/** uwf thread steps — single step entry */
|
||||||
@@ -126,6 +128,19 @@ export type ThreadListItem = {
|
|||||||
head: CasRef;
|
head: CasRef;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** uwf thread running — single running thread entry */
|
||||||
|
export type RunningThreadItem = {
|
||||||
|
thread: ThreadId;
|
||||||
|
workflow: CasRef;
|
||||||
|
pid: number;
|
||||||
|
startedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** uwf thread running output */
|
||||||
|
export type RunningThreadsOutput = {
|
||||||
|
threads: RunningThreadItem[];
|
||||||
|
};
|
||||||
|
|
||||||
// ── 4.6 配置 ────────────────────────────────────────────────────────
|
// ── 4.6 配置 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Alias types for config references */
|
/** Alias types for config references */
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { AgentContext } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
|
/** Max characters of step content to include in the prompt. */
|
||||||
|
const CONTENT_QUOTA = 16_000;
|
||||||
|
|
||||||
|
/** Builds the full agent prompt: system instructions plus summarized thread history. */
|
||||||
|
export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(ctx.currentRole.systemPrompt);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("## Task");
|
||||||
|
lines.push(ctx.start.content);
|
||||||
|
|
||||||
|
const { steps } = ctx;
|
||||||
|
if (steps.length === 0) {
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (steps.length === 1) {
|
||||||
|
const s = steps[0];
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`## Step: ${s.role}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`Meta: ${JSON.stringify(s.meta)}`);
|
||||||
|
appendContent(lines, s.content);
|
||||||
|
} else {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("## Previous Steps");
|
||||||
|
for (let i = 0; i < steps.length - 1; i++) {
|
||||||
|
const s = steps[i];
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`### Step ${i + 1}: ${s.role}`);
|
||||||
|
lines.push(`Summary: ${JSON.stringify(s.meta)}`);
|
||||||
|
}
|
||||||
|
const last = steps[steps.length - 1];
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`## Latest Step: ${last.role}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`Meta: ${JSON.stringify(last.meta)}`);
|
||||||
|
appendContent(lines, last.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
lines.push("## Tools");
|
||||||
|
lines.push(
|
||||||
|
`Use \`uncaged-workflow thread ${ctx.threadId}\` to read full details of any previous step.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendContent(lines: string[], content: string | null | undefined): void {
|
||||||
|
if (content === null || content === undefined || content.trim() === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const truncated =
|
||||||
|
content.length > CONTENT_QUOTA
|
||||||
|
? `${content.slice(0, CONTENT_QUOTA)}\n... (truncated)`
|
||||||
|
: content;
|
||||||
|
lines.push("");
|
||||||
|
lines.push("<output>");
|
||||||
|
lines.push(truncated);
|
||||||
|
lines.push("</output>");
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ All exports come from `src/index.ts`.
|
|||||||
```typescript
|
```typescript
|
||||||
function encodeUint64AsCrockford(value: bigint): string
|
function encodeUint64AsCrockford(value: bigint): string
|
||||||
function generateUlid(nowMs: number): string
|
function generateUlid(nowMs: number): string
|
||||||
|
function extractUlidTimestamp(ulid: string): number | null
|
||||||
```
|
```
|
||||||
|
|
||||||
### Logging
|
### Logging
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { extractUlidTimestamp, generateUlid } from "../ulid.js";
|
||||||
|
|
||||||
|
describe("extractUlidTimestamp", () => {
|
||||||
|
it("should extract correct timestamp from ULID", () => {
|
||||||
|
const knownTimestamp = Date.UTC(2026, 4, 20, 0, 0, 0);
|
||||||
|
const ulid = generateUlid(knownTimestamp);
|
||||||
|
const extracted = extractUlidTimestamp(ulid);
|
||||||
|
expect(extracted).toBe(knownTimestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle epoch timestamp (timestamp 0)", () => {
|
||||||
|
const ulid = generateUlid(0);
|
||||||
|
const extracted = extractUlidTimestamp(ulid);
|
||||||
|
expect(extracted).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle recent timestamps", () => {
|
||||||
|
const recentTimestamp = Date.now();
|
||||||
|
const ulid = generateUlid(recentTimestamp);
|
||||||
|
const extracted = extractUlidTimestamp(ulid);
|
||||||
|
expect(extracted).toBe(recentTimestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle max 48-bit timestamp", () => {
|
||||||
|
const maxTimestamp = 2 ** 48 - 1;
|
||||||
|
const ulid = generateUlid(maxTimestamp);
|
||||||
|
const extracted = extractUlidTimestamp(ulid);
|
||||||
|
expect(extracted).toBe(maxTimestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for invalid ULID length", () => {
|
||||||
|
expect(extractUlidTimestamp("")).toBe(null);
|
||||||
|
expect(extractUlidTimestamp("TOOSHORT")).toBe(null);
|
||||||
|
expect(extractUlidTimestamp("TOOLONGAAAAAAAAAAAAAAAAAA")).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for invalid Crockford Base32 characters", () => {
|
||||||
|
expect(extractUlidTimestamp("INVALID!@#$%^&CHARACTERS")).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should extract timestamps from multiple ULIDs correctly", () => {
|
||||||
|
const timestamps = [
|
||||||
|
Date.UTC(2020, 0, 1, 0, 0, 0),
|
||||||
|
Date.UTC(2023, 5, 15, 12, 30, 45),
|
||||||
|
Date.UTC(2026, 11, 31, 23, 59, 59),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const ts of timestamps) {
|
||||||
|
const ulid = generateUlid(ts);
|
||||||
|
const extracted = extractUlidTimestamp(ulid);
|
||||||
|
expect(extracted).toBe(ts);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@ uwf setup --provider <name> --base-url <url> \\
|
|||||||
## Workflow Commands
|
## Workflow Commands
|
||||||
|
|
||||||
\`\`\`
|
\`\`\`
|
||||||
uwf workflow put <file> # register a workflow from YAML file
|
uwf workflow add <file> # register a workflow from YAML file
|
||||||
uwf workflow show <id> # show workflow by name or CAS hash
|
uwf workflow show <id> # show workflow by name or CAS hash
|
||||||
uwf workflow list # list all registered workflows
|
uwf workflow list # list all registered workflows
|
||||||
\`\`\`
|
\`\`\`
|
||||||
@@ -24,20 +24,27 @@ uwf workflow list # list all registered workflows
|
|||||||
|
|
||||||
\`\`\`
|
\`\`\`
|
||||||
uwf thread start <workflow> -p <prompt> # create a thread (no execution)
|
uwf thread start <workflow> -p <prompt> # create a thread (no execution)
|
||||||
uwf thread step <thread-id> # execute one moderator→agent→extract cycle
|
uwf thread exec <thread-id> # execute one moderator→agent→extract cycle
|
||||||
[--agent <cmd>] # override agent command
|
[--agent <cmd>] # override agent command
|
||||||
[-c, --count <number>] # run multiple steps (default: 1)
|
[-c, --count <number>] # run multiple steps (default: 1)
|
||||||
|
[--background] # run in background
|
||||||
uwf thread show <thread-id> # show thread head pointer
|
uwf thread show <thread-id> # show thread head pointer
|
||||||
uwf thread list # list active threads
|
uwf thread list # list threads
|
||||||
[--all] # include archived threads
|
[--status <status>] # filter: idle, running, or completed
|
||||||
uwf thread kill <thread-id> # terminate and archive a thread
|
|
||||||
uwf thread steps <thread-id> # list all steps in a thread
|
|
||||||
uwf thread read <thread-id> # render thread context as markdown
|
uwf thread read <thread-id> # render thread context as markdown
|
||||||
[--quota <chars>] # max output characters (default 32000)
|
[--quota <chars>] # max output characters (default 32000)
|
||||||
[--before <step-hash>] # load steps before this hash (exclusive)
|
[--before <step-hash>] # load steps before this hash (exclusive)
|
||||||
[--start] # include start step in output
|
[--start] # include start step in output
|
||||||
uwf thread fork <step-hash> # fork a thread from a specific step
|
uwf thread stop <thread-id> # stop background execution (keep thread active)
|
||||||
uwf thread step-details <step-hash> # dump full detail node of a step as YAML
|
uwf thread cancel <thread-id> # cancel thread (stop + move to history)
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Step Commands
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
uwf step list <thread-id> # list all steps in a thread
|
||||||
|
uwf step show <step-hash> # show details of a specific step
|
||||||
|
uwf step fork <step-hash> # fork a thread from a specific step
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
## CAS Commands
|
## CAS Commands
|
||||||
@@ -78,10 +85,9 @@ uwf -V, --version # print version
|
|||||||
## Key Concepts
|
## Key Concepts
|
||||||
|
|
||||||
- **Workflow**: YAML definition with roles, conditions, and a routing graph; stored as a CAS node identified by its XXH64 hash.
|
- **Workflow**: YAML definition with roles, conditions, and a routing graph; stored as a CAS node identified by its XXH64 hash.
|
||||||
- **Thread**: A single workflow execution (ULID). State is an immutable CAS chain; active threads are indexed in \`threads.yaml\`.
|
- **Thread**: A running instance of a workflow; points to a chain of CAS step nodes.
|
||||||
- **Step**: One moderator→agent→extract cycle. Run \`uwf thread step\` repeatedly until \`$END\`.
|
- **Step**: One moderator→agent→extract cycle; stored as a CAS node with output + detail refs.
|
||||||
- **CAS**: Content-Addressed Storage — all nodes are immutable and identified by hash.
|
- **Turn**: Agent-internal interaction (within a single step); stored per-turn in the detail node.
|
||||||
- **Role**: Named actor with goal, capabilities, procedure, output, and frontmatter schema; the moderator routes between roles.
|
- **CAS**: Content-addressable store; every artifact (workflows, steps, details, turns) is hashed.
|
||||||
- **Edge Prompt**: Required instruction on each graph edge — the moderator's dispatch message to the agent.
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,4 +24,4 @@ export { normalizeRefsField } from "./refs-field.js";
|
|||||||
export { err, ok } from "./result.js";
|
export { err, ok } from "./result.js";
|
||||||
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
|
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
|
||||||
export type { LogFn, Result } from "./types.js";
|
export type { LogFn, Result } from "./types.js";
|
||||||
export { generateUlid } from "./ulid.js";
|
export { extractUlidTimestamp, generateUlid } from "./ulid.js";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { encodeCrockfordBase32Bits } from "./base32.js";
|
import { decodeCrockfordBase32Bits, encodeCrockfordBase32Bits } from "./base32.js";
|
||||||
|
|
||||||
const ULID_TIME_BITS = 48;
|
const ULID_TIME_BITS = 48;
|
||||||
const ULID_RANDOM_BITS = 80;
|
const ULID_RANDOM_BITS = 80;
|
||||||
@@ -26,3 +26,19 @@ export function generateUlid(nowMs: number): string {
|
|||||||
const payload = (time << BigInt(ULID_RANDOM_BITS)) | rand;
|
const payload = (time << BigInt(ULID_RANDOM_BITS)) | rand;
|
||||||
return encodeCrockfordBase32Bits(payload, ULID_TIME_BITS + ULID_RANDOM_BITS);
|
return encodeCrockfordBase32Bits(payload, ULID_TIME_BITS + ULID_RANDOM_BITS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the timestamp (in milliseconds) from a ULID string.
|
||||||
|
* Returns null if the ULID is invalid.
|
||||||
|
*/
|
||||||
|
export function extractUlidTimestamp(ulid: string): number | null {
|
||||||
|
if (ulid.length !== 26) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const timestampPart = ulid.slice(0, 10);
|
||||||
|
const decoded = decodeCrockfordBase32Bits(timestampPart, ULID_TIME_BITS);
|
||||||
|
if (!decoded.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Number(decoded.value);
|
||||||
|
}
|
||||||
|
|||||||
Executable
+89
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# batch-solve.sh — solve multiple Gitea issues via solve-issue workflow
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/batch-solve.sh [--agent CMD] [--repo OWNER/REPO] [--count N] ISSUE_NUM...
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# ./scripts/batch-solve.sh 448 449
|
||||||
|
# ./scripts/batch-solve.sh --agent "bun run $(pwd)/packages/workflow-agent-claude-code/src/cli.ts" 448 449
|
||||||
|
# ./scripts/batch-solve.sh --repo uncaged/workflow --count 15 448 449
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
AGENT=""
|
||||||
|
REPO="uncaged/workflow"
|
||||||
|
COUNT=10
|
||||||
|
ISSUES=()
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--agent) AGENT="$2"; shift 2 ;;
|
||||||
|
--repo) REPO="$2"; shift 2 ;;
|
||||||
|
--count) COUNT="$2"; shift 2 ;;
|
||||||
|
*) ISSUES+=("$1"); shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ${#ISSUES[@]} -eq 0 ]]; then
|
||||||
|
echo "Usage: $0 [--agent CMD] [--repo OWNER/REPO] [--count N] ISSUE_NUM..." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
AGENT_FLAG=""
|
||||||
|
if [[ -n "$AGENT" ]]; then
|
||||||
|
AGENT_FLAG="--agent $AGENT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
TOTAL=${#ISSUES[@]}
|
||||||
|
PASSED=0
|
||||||
|
FAILED=0
|
||||||
|
RESULTS=()
|
||||||
|
|
||||||
|
echo "━━━ Batch solve: ${TOTAL} issues ━━━"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
for i in "${!ISSUES[@]}"; do
|
||||||
|
ISSUE="${ISSUES[$i]}"
|
||||||
|
NUM=$((i + 1))
|
||||||
|
echo "┌─── [$NUM/$TOTAL] Issue #${ISSUE} ───"
|
||||||
|
|
||||||
|
# Read issue title
|
||||||
|
TITLE=$(tea issues "$ISSUE" -r "$REPO" 2>/dev/null | head -1 | sed 's/^# #[0-9]* //' | sed 's/ (.*//' || echo "unknown")
|
||||||
|
echo "│ Title: $TITLE"
|
||||||
|
|
||||||
|
# Start thread
|
||||||
|
PROMPT="Fix issue #${ISSUE} in ${REPO}. Read the issue first with 'tea issues ${ISSUE} -r ${REPO}' for full spec."
|
||||||
|
THREAD_JSON=$(uwf thread start solve-issue -p "$PROMPT" 2>&1)
|
||||||
|
THREAD_ID=$(echo "$THREAD_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin)['thread'])")
|
||||||
|
echo "│ Thread: $THREAD_ID"
|
||||||
|
|
||||||
|
# Run steps
|
||||||
|
echo "│ Running (max $COUNT steps)..."
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
if STEP_OUTPUT=$(uwf thread step "$THREAD_ID" $AGENT_FLAG -c "$COUNT" 2>&1); then
|
||||||
|
# Check if done
|
||||||
|
LAST_DONE=$(echo "$STEP_OUTPUT" | python3 -c "import json,sys; lines=sys.stdin.read().strip(); data=json.loads(lines); print(data[-1].get('done', False))")
|
||||||
|
if [[ "$LAST_DONE" == "True" ]]; then
|
||||||
|
echo "│ ✅ Done!"
|
||||||
|
PASSED=$((PASSED + 1))
|
||||||
|
RESULTS+=("✅ #${ISSUE} — ${TITLE}")
|
||||||
|
else
|
||||||
|
echo "│ ⚠️ Ran out of steps (not done)"
|
||||||
|
FAILED=$((FAILED + 1))
|
||||||
|
RESULTS+=("⚠️ #${ISSUE} — ${TITLE} (incomplete)")
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "│ ❌ Failed"
|
||||||
|
FAILED=$((FAILED + 1))
|
||||||
|
RESULTS+=("❌ #${ISSUE} — ${TITLE} (error)")
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "└───"
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "━━━ Results: ${PASSED}/${TOTAL} passed, ${FAILED} failed ━━━"
|
||||||
|
for R in "${RESULTS[@]}"; do
|
||||||
|
echo " $R"
|
||||||
|
done
|
||||||
Reference in New Issue
Block a user