Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a7f417899 | |||
| d00f9df2dd | |||
| ff959be3ef | |||
| f45563ee31 | |||
| 2b8cd99100 | |||
| 1ca13e02b2 | |||
| 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 | |||
| bf31fa0d03 | |||
| c39f2f3e63 | |||
| 6481fc0cc5 | |||
| 3190e06ebe | |||
| f8ae2fe25b | |||
| ffc31a8c19 | |||
| 48a274685b | |||
| 5b68359dfc | |||
| c2ddfb8558 | |||
| 603018caf2 | |||
| aff0ee6fea | |||
| d37fa1393a | |||
| 759c784267 | |||
| 52ffc7dcc1 | |||
| ac55a3e3d9 | |||
| edb979baa9 | |||
| 3d1850ddbe | |||
| 3c1f4a6dfa | |||
| f07a6daa30 | |||
| 0eeb4a8ed8 | |||
| a3fac708b6 | |||
| 52879c0028 | |||
| 8720eb19af | |||
| 9e4527bb89 | |||
| 5209cfa7ac | |||
| 155b879d29 | |||
| c1f04929f4 | |||
| 50cd93aa05 | |||
| 1abc3b4cf4 | |||
| 330db43b5f | |||
| 211f38bc8d | |||
| 613793e128 | |||
| 080792a6c0 | |||
| 43cbf4127f | |||
| 9f95956e19 | |||
| 65e2305761 | |||
| bc64f2613b |
@@ -0,0 +1,67 @@
|
||||
# Sync README
|
||||
|
||||
When updating README.md files in this monorepo, follow these conventions.
|
||||
|
||||
## Scope
|
||||
|
||||
- Root `README.md` — project overview and navigation hub
|
||||
- Per-package `packages/*/README.md` — each package self-contained
|
||||
|
||||
## Root README Structure
|
||||
|
||||
The root README should have these sections in order:
|
||||
|
||||
1. **Title and one-liner** — stateless workflow engine driven by single-step CLI
|
||||
2. **Overview** — 2-3 paragraphs explaining what it does and key concepts
|
||||
3. **Architecture** — dependency layer diagram (text-based)
|
||||
4. **Packages** — table with ALL packages from packages/ directory, columns: Package, Description, Type (cli/lib/agent/app)
|
||||
5. **Quick Start** — install, build, register workflow, start thread, run step
|
||||
6. **CLI Reference** — brief command list, detailed usage in cli-workflow README
|
||||
7. **Development** — bun install / build / check / test
|
||||
|
||||
## Per-Package README Structure
|
||||
|
||||
Each package README should have:
|
||||
|
||||
1. **Title** — package name
|
||||
2. **One-line description** — matching package.json
|
||||
3. **Overview** — what it does, where it sits in the architecture, dependencies
|
||||
4. **Installation** — bun add (for libs) or "included as binary" (for cli/agents)
|
||||
5. **API** (lib packages) — all exports from src/index.ts with type signatures, grouped by category, minimal usage examples
|
||||
6. **CLI Usage** (cli/agent packages) — command reference with examples
|
||||
7. **Internal Structure** — brief src/ file organization
|
||||
8. **Configuration** (if applicable)
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 1: Gather current state
|
||||
For each package read:
|
||||
- package.json (name, version, description, dependencies, bin)
|
||||
- src/index.ts (public API exports)
|
||||
- Existing README.md (preserve hand-written content worth keeping)
|
||||
|
||||
### Step 2: Update root README
|
||||
- Ensure ALL packages in packages/ directory are listed in the table
|
||||
- Update CLI command reference from uwf --help output
|
||||
- Keep Quick Start examples valid
|
||||
|
||||
### Step 3: Write/update each package README
|
||||
- Follow the per-package structure
|
||||
- API section MUST match actual src/index.ts exports — never invent
|
||||
- For agent packages: document CLI binary name, how it is invoked
|
||||
- For lib packages: document exported types and functions
|
||||
- Internal structure: list actual files in src/
|
||||
|
||||
### Step 4: Verify
|
||||
- All relative links work
|
||||
- Package names match package.json
|
||||
- No references to removed/renamed packages
|
||||
- bun run build still passes
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Only document what src/index.ts actually exports
|
||||
- Root README summarizes, package READMEs go into detail
|
||||
- Verify CLI examples against actual commands
|
||||
- Preserve existing good prose when updating
|
||||
- English for all README content
|
||||
+31
-11
@@ -38,18 +38,26 @@ roles:
|
||||
capabilities:
|
||||
- coding
|
||||
procedure: |
|
||||
Before starting any work, ensure a clean worktree:
|
||||
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
|
||||
- If bounced back from reviewer or tester, reuse the existing branch instead
|
||||
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
|
||||
|
||||
Before starting any work, set up an isolated worktree:
|
||||
1. `cd ~/repos/workflow && git fetch origin` to get latest refs
|
||||
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:
|
||||
3. 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
|
||||
5. Write tests first based on the spec
|
||||
6. Implement the code to make tests pass
|
||||
7. Ensure `bun run build` passes with no errors
|
||||
8. Run `bun test` to verify all tests pass
|
||||
5. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
|
||||
6. If bounced back from reviewer or tester: read the previous role's output to understand what needs fixing
|
||||
7. Write tests first based on the spec
|
||||
8. Implement the code to make tests pass
|
||||
9. Ensure `bun run build` passes with no errors
|
||||
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)."
|
||||
frontmatter:
|
||||
type: object
|
||||
@@ -65,6 +73,8 @@ roles:
|
||||
- code-review
|
||||
- static-analysis
|
||||
procedure: |
|
||||
First, cd into the worktree: `cd ~/repos/workflow-worktrees/fix/<issue-number>-*` (find the exact directory)
|
||||
|
||||
Before reviewing, verify the git branch:
|
||||
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
|
||||
@@ -98,6 +108,8 @@ roles:
|
||||
capabilities:
|
||||
- testing
|
||||
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
|
||||
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
|
||||
@@ -118,13 +130,21 @@ roles:
|
||||
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
|
||||
capabilities: []
|
||||
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.
|
||||
1. Stage all changes: `git add -A`
|
||||
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>`
|
||||
- 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
|
||||
- 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)."
|
||||
frontmatter:
|
||||
type: object
|
||||
|
||||
@@ -2,92 +2,103 @@
|
||||
|
||||
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions with roles, JSONata routing conditions, and a directed graph. Threads are immutable CAS-linked chains — each `uwf thread step` runs one moderator→agent→extract cycle and exits.
|
||||
|
||||
## Package Map
|
||||
## Overview
|
||||
|
||||
| Package | npm | Role |
|
||||
|---------|-----|------|
|
||||
| `cli-workflow` | `@uncaged/cli-workflow` | `uwf` CLI binary — thread lifecycle, workflow registry, CAS inspection, setup |
|
||||
| `workflow-protocol` | `@uncaged/workflow-protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `WorkflowConfig`, etc.) |
|
||||
| `workflow-moderator` | `@uncaged/workflow-moderator` | JSONata graph evaluator — determines next role or `$END` |
|
||||
| `workflow-agent-kit` | `@uncaged/workflow-agent-kit` | `createAgent` factory, context builder, two-layer extract pipeline |
|
||||
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | `uwf-hermes` agent — spawns Hermes chat, captures session |
|
||||
| `workflow-util` | `@uncaged/workflow-util` | Crockford Base32, ULID, logger, frontmatter parsing |
|
||||
This monorepo implements **uwf**, a workflow engine with no long-running daemon. You register YAML workflow definitions in a content-addressed store (CAS), start a thread with an initial prompt, then invoke `uwf thread step` repeatedly until the moderator routes to `$END`. Each step is a complete process: the moderator evaluates JSONata conditions to pick the next role, an external agent CLI produces frontmatter markdown output, and an extract pipeline validates or structures that output against the role's JSON Schema.
|
||||
|
||||
External: [`@uncaged/json-cas`](https://www.npmjs.com/package/@uncaged/json-cas) (CAS store + JSON Schema validation) + `@uncaged/json-cas-fs` (filesystem backend).
|
||||
Workflow state lives entirely on disk under `~/.uncaged/workflow/`: CAS nodes for definitions and step payloads, `registry.yaml` for workflow name→hash mappings, and `threads.yaml` for active thread head pointers. Completed threads are archived to `history.jsonl`. Because there is no server process, workflows are easy to debug, fork, and inspect with ordinary CLI tools.
|
||||
|
||||
Agents are pluggable CLI binaries (`uwf-hermes`, `uwf-builtin`, `uwf-claude-code`, or custom commands). The engine spawns the configured agent with `<thread-id>` and `<role>`, sets `UWF_EDGE_PROMPT` from the graph transition, and captures both the agent's markdown output and a detail CAS node for session replay.
|
||||
|
||||
## Architecture
|
||||
|
||||
Dependency layers (lower layers have no dependency on higher layers):
|
||||
|
||||
```
|
||||
Layer 0 — Contract
|
||||
workflow-protocol Shared types and JSON Schema definitions
|
||||
|
||||
Layer 1 — Shared infra
|
||||
workflow-util Encoding, IDs, logging, frontmatter, paths
|
||||
workflow-moderator JSONata graph evaluator
|
||||
|
||||
Layer 2 — Agent framework
|
||||
workflow-agent-kit createAgent factory, context builder, extract pipeline
|
||||
|
||||
Layer 3 — Agent implementations
|
||||
workflow-agent-hermes Hermes ACP agent (uwf-hermes)
|
||||
workflow-agent-builtin Built-in LLM + tools agent (uwf-builtin)
|
||||
workflow-agent-claude-code Claude Code agent (uwf-claude-code)
|
||||
|
||||
Layer 4 — CLI
|
||||
cli-workflow uwf binary — thread lifecycle, registry, CAS, setup
|
||||
|
||||
App (uses protocol; not in the runtime engine stack)
|
||||
workflow-dashboard Web UI for visual workflow editing
|
||||
```
|
||||
|
||||
External CAS: [`@uncaged/json-cas`](https://www.npmjs.com/package/@uncaged/json-cas) (store API, hashing, schema validation) + `@uncaged/json-cas-fs` (filesystem backend).
|
||||
|
||||
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, CAS node types, storage layout, agent CLI protocol, and design decisions.
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | npm | Description | Type | README |
|
||||
|---------|-----|-------------|------|--------|
|
||||
| `cli-workflow` | `@uncaged/cli-workflow` | `uwf` CLI — thread lifecycle, workflow registry, CAS inspection, setup | cli | [README](packages/cli-workflow/README.md) |
|
||||
| `workflow-protocol` | `@uncaged/workflow-protocol` | Shared TypeScript types and JSON Schema constants | lib | [README](packages/workflow-protocol/README.md) |
|
||||
| `workflow-moderator` | `@uncaged/workflow-moderator` | JSONata graph evaluator — next role or `$END` | lib | [README](packages/workflow-moderator/README.md) |
|
||||
| `workflow-agent-kit` | `@uncaged/workflow-agent-kit` | `createAgent` factory, context builder, extract pipeline | lib | [README](packages/workflow-agent-kit/README.md) |
|
||||
| `workflow-util` | `@uncaged/workflow-util` | Crockford Base32, ULID, logger, frontmatter parsing, storage paths | lib | [README](packages/workflow-util/README.md) |
|
||||
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | `uwf-hermes` — spawns Hermes chat via ACP | agent | [README](packages/workflow-agent-hermes/README.md) |
|
||||
| `workflow-agent-builtin` | `@uncaged/workflow-agent-builtin` | `uwf-builtin` — built-in LLM agent with file/shell tools | agent | [README](packages/workflow-agent-builtin/README.md) |
|
||||
| `workflow-agent-claude-code` | `@uncaged/workflow-agent-claude-code` | `uwf-claude-code` — spawns Claude Code CLI | agent | [README](packages/workflow-agent-claude-code/README.md) |
|
||||
| `workflow-dashboard` | `@uncaged/workflow-dashboard` | Web graph editor for workflow YAML (private, alpha) | app | [README](packages/workflow-dashboard/README.md) |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Configure provider and model
|
||||
# 1. Configure provider, model, and default agent
|
||||
uwf setup
|
||||
|
||||
# 2. Register a workflow from YAML
|
||||
uwf workflow put examples/solve-issue.yaml
|
||||
uwf workflow add examples/solve-issue.yaml
|
||||
|
||||
# 3. Start a thread
|
||||
# 3. Start a thread (creates head pointer; does not execute)
|
||||
uwf thread start solve-issue -p "Fix the login redirect bug"
|
||||
|
||||
# 4. Execute steps (one at a time, until done)
|
||||
uwf thread step <thread-id>
|
||||
uwf thread exec <thread-id>
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
Use `-c, --count <number>` on `thread exec` to run multiple steps in one invocation. Override the agent with `--agent <cmd>`.
|
||||
|
||||
### Thread
|
||||
## CLI Reference
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf thread start <workflow> -p <prompt>` | Create a thread (no execution) |
|
||||
| `uwf thread step <thread-id> [--agent <cmd>]` | Execute one moderator→agent→extract cycle |
|
||||
| `uwf thread show <thread-id>` | Show head pointer and done status |
|
||||
| `uwf thread list [--all]` | List threads (`--all` includes archived) |
|
||||
| `uwf thread steps <thread-id>` | List all steps chronologically |
|
||||
| `uwf thread read <thread-id> [--quota N]` | 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 |
|
||||
| `uwf thread kill <thread-id>` | Terminate and archive |
|
||||
Global options: `-V, --version`, `--format <json|yaml>`, `-h, --help`.
|
||||
|
||||
### Workflow
|
||||
| Group | Commands |
|
||||
|-------|----------|
|
||||
| **thread** | `start`, `exec`, `show`, `list`, `stop`, `cancel`, `read` |
|
||||
| **step** | `list`, `show`, `read`, `fork` |
|
||||
| **workflow** | `add`, `show`, `list` |
|
||||
| **cas** | `get`, `put`, `put-text`, `has`, `refs`, `walk`, `reindex`, `schema list`, `schema get` |
|
||||
| **setup** | Interactive or `--provider`, `--base-url`, `--api-key`, `--model`, `--agent` |
|
||||
| **skill** | `cli` — print markdown reference of all uwf commands |
|
||||
| **log** | `list`, `show`, `clean` — process-level debug logs |
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf workflow put <file.yaml>` | Register a workflow from YAML |
|
||||
| `uwf workflow show <name-or-hash>` | Show workflow definition |
|
||||
| `uwf workflow list` | List registered workflows |
|
||||
Config is stored in `~/.uncaged/workflow/config.yaml`. API keys go in `~/.uncaged/workflow/.env`.
|
||||
|
||||
### CAS
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf cas get <hash>` | Read a CAS node |
|
||||
| `uwf cas put <type-hash> <data>` | Store a node |
|
||||
| `uwf cas has <hash>` | Check existence |
|
||||
| `uwf cas refs <hash>` | List direct references |
|
||||
| `uwf cas walk <hash>` | Recursive traversal |
|
||||
| `uwf cas reindex` | Rebuild type index |
|
||||
| `uwf cas schema list` | List schemas |
|
||||
| `uwf cas schema get <hash>` | Show a schema |
|
||||
|
||||
### Setup
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf setup` | Interactive provider/model/agent configuration |
|
||||
| `uwf setup --provider ... --base-url ... --api-key ... --model ...` | Non-interactive setup |
|
||||
|
||||
Config stored in `~/.uncaged/workflow/config.yaml`. API keys in `~/.uncaged/workflow/.env`.
|
||||
Detailed command usage, options, and examples: [packages/cli-workflow/README.md](packages/cli-workflow/README.md).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
bun install --no-cache # Install dependencies
|
||||
bun run build # tsc --build (all packages)
|
||||
bun run check # tsc + biome + lint-log-tags
|
||||
bun run format # Auto-format with Biome
|
||||
bun test # Run all tests
|
||||
```
|
||||
|
||||
Managed with **bun workspace**. See [CLAUDE.md](CLAUDE.md) for coding conventions.
|
||||
|
||||
## Architecture
|
||||
|
||||
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, CAS node types, storage layout, agent CLI protocol, and design decisions.
|
||||
|
||||
@@ -17,6 +17,15 @@
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"cssModules": true,
|
||||
"tailwindDirectives": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
|
||||
@@ -22,6 +22,8 @@ roles:
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
enum: ["_"]
|
||||
thesis:
|
||||
type: string
|
||||
keyPoints:
|
||||
@@ -30,14 +32,9 @@ roles:
|
||||
type: string
|
||||
caveats:
|
||||
type: string
|
||||
required: [thesis, keyPoints]
|
||||
conditions: {}
|
||||
required: [status, thesis, keyPoints]
|
||||
graph:
|
||||
$START:
|
||||
- role: "analyst"
|
||||
condition: null
|
||||
prompt: "Analyze the topic in the task and produce a structured summary with key points."
|
||||
_: { role: "analyst", prompt: "Analyze the topic in the task and produce a structured summary with key points." }
|
||||
analyst:
|
||||
- role: "$END"
|
||||
condition: null
|
||||
prompt: "Analysis complete. Finish the workflow."
|
||||
_: { role: "$END", prompt: "Analysis complete. Finish the workflow." }
|
||||
|
||||
+15
-30
@@ -16,15 +16,16 @@ roles:
|
||||
3. If you find yourself genuinely convinced by the other side, you may concede.
|
||||
output: |
|
||||
Provide your argument in the frontmatter.
|
||||
Set conceded to true ONLY if you are genuinely convinced and wish to stop debating.
|
||||
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
|
||||
Otherwise set status to "continue".
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
enum: ["continue", "conceded"]
|
||||
argument:
|
||||
type: string
|
||||
conceded:
|
||||
type: boolean
|
||||
required: [argument, conceded]
|
||||
required: [status, argument]
|
||||
for:
|
||||
description: "Argues for the proposition"
|
||||
goal: |
|
||||
@@ -40,38 +41,22 @@ roles:
|
||||
3. If you find yourself genuinely convinced by the other side, you may concede.
|
||||
output: |
|
||||
Provide your argument in the frontmatter.
|
||||
Set conceded to true ONLY if you are genuinely convinced and wish to stop debating.
|
||||
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
|
||||
Otherwise set status to "continue".
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
enum: ["continue", "conceded"]
|
||||
argument:
|
||||
type: string
|
||||
conceded:
|
||||
type: boolean
|
||||
required: [argument, conceded]
|
||||
conditions:
|
||||
againstConceded:
|
||||
description: "The against side conceded"
|
||||
expression: "$last('against').conceded = true"
|
||||
forConceded:
|
||||
description: "The for side conceded"
|
||||
expression: "$last('for').conceded = true"
|
||||
required: [status, argument]
|
||||
graph:
|
||||
$START:
|
||||
- role: "against"
|
||||
condition: null
|
||||
prompt: "Present your opening argument against the proposition."
|
||||
_: { role: "against", prompt: "Present your opening argument against the proposition." }
|
||||
against:
|
||||
- role: "$END"
|
||||
condition: "againstConceded"
|
||||
prompt: "The against side conceded. Debate over."
|
||||
- role: "for"
|
||||
condition: null
|
||||
prompt: "Counter the opposing argument. Address their points directly."
|
||||
conceded: { role: "$END", prompt: "The against side conceded. Debate over." }
|
||||
continue: { role: "for", prompt: "Counter the opposing argument: {{{argument}}}" }
|
||||
for:
|
||||
- role: "$END"
|
||||
condition: "forConceded"
|
||||
prompt: "The for side conceded. Debate over."
|
||||
- role: "against"
|
||||
condition: null
|
||||
prompt: "Counter the opposing argument. Address their points directly."
|
||||
conceded: { role: "$END", prompt: "The for side conceded. Debate over." }
|
||||
continue: { role: "against", prompt: "Counter the opposing argument: {{{argument}}}" }
|
||||
|
||||
+45
-33
@@ -3,22 +3,37 @@ description: "End-to-end issue resolution"
|
||||
roles:
|
||||
planner:
|
||||
description: "Creates implementation plan"
|
||||
goal: "You are a planning agent. You analyze issues and create step-by-step plans."
|
||||
goal: "You are a planning agent. You analyze issues and create implementation plans grounded in the actual codebase."
|
||||
capabilities:
|
||||
- issue-analysis
|
||||
- planning
|
||||
procedure: "Analyze the issue and create a detailed, actionable implementation plan."
|
||||
output: "Output the plan summary and list of concrete steps."
|
||||
- file-read
|
||||
- shell
|
||||
procedure: |
|
||||
1. Locate the code repository:
|
||||
- Check if the current working directory is the repo (look for package.json, .git, etc.)
|
||||
- If the task mentions a repo URL, clone it first.
|
||||
- If this is a new project, create the repo and note the path.
|
||||
2. Explore the codebase — read the relevant source files mentioned in the issue. Understand the current architecture, types, and conventions (check CLAUDE.md, CONTRIBUTING.md, .cursor/rules/).
|
||||
3. Identify which files need changes and what the changes should be, with specific code references.
|
||||
4. Output the plan with:
|
||||
- `repoPath`: absolute path to the repository root
|
||||
- `plan`: detailed implementation plan with file paths and code references
|
||||
- `steps`: concrete action items for the developer
|
||||
output: |
|
||||
Provide repoPath, plan summary, and steps in the frontmatter.
|
||||
The plan MUST reference actual file paths and code structures you found by reading the source.
|
||||
Do NOT guess — if you haven't read a file, read it before referencing it.
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
enum: ["_"]
|
||||
repoPath:
|
||||
type: string
|
||||
plan:
|
||||
type: string
|
||||
steps:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
required: [plan, steps]
|
||||
required: [status, repoPath, plan]
|
||||
developer:
|
||||
description: "Implements code changes"
|
||||
goal: "You are a developer agent. You implement code changes according to plans."
|
||||
@@ -26,55 +41,52 @@ roles:
|
||||
- file-edit
|
||||
- shell
|
||||
- testing
|
||||
procedure: "Implement the plan. Write code, tests, and ensure existing tests pass."
|
||||
procedure: |
|
||||
1. Read the planner's output to get the repoPath and implementation plan.
|
||||
2. cd to the repoPath before making any changes.
|
||||
3. Create a feature branch from the default branch.
|
||||
4. Implement the plan — write code, tests, and ensure existing tests pass.
|
||||
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."
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
enum: ["_"]
|
||||
filesChanged:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
required: [filesChanged, summary]
|
||||
required: [status, filesChanged, summary]
|
||||
reviewer:
|
||||
description: "Reviews code changes"
|
||||
goal: "You are a code reviewer. You review implementations for correctness and quality."
|
||||
capabilities:
|
||||
- code-review
|
||||
- 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."
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
approved:
|
||||
type: boolean
|
||||
status:
|
||||
enum: ["approved", "rejected"]
|
||||
comments:
|
||||
type: string
|
||||
required: [approved, comments]
|
||||
conditions:
|
||||
notApproved:
|
||||
description: "Reviewer rejected the implementation"
|
||||
expression: "$last('reviewer').approved = false"
|
||||
required: [status, comments]
|
||||
graph:
|
||||
$START:
|
||||
- role: "planner"
|
||||
condition: null
|
||||
prompt: "Analyze the issue described in the task and produce a detailed implementation plan."
|
||||
_: { role: "planner", prompt: "Analyze the issue described in the task and produce a detailed implementation plan." }
|
||||
planner:
|
||||
- role: "developer"
|
||||
condition: null
|
||||
prompt: "Implement the plan from the planner. Write code, tests, and ensure existing tests pass."
|
||||
_: { role: "developer", prompt: "Implement the plan from the planner. Write code, tests, and ensure existing tests pass." }
|
||||
developer:
|
||||
- role: "reviewer"
|
||||
condition: null
|
||||
prompt: "Review the developer's implementation against the plan for correctness and quality."
|
||||
_: { role: "reviewer", prompt: "Review the developer's implementation against the plan for correctness and quality." }
|
||||
reviewer:
|
||||
- role: "developer"
|
||||
condition: "notApproved"
|
||||
prompt: "The reviewer rejected your implementation. Read their feedback and fix the issues."
|
||||
- role: "$END"
|
||||
condition: null
|
||||
prompt: "The review passed. Complete the workflow."
|
||||
approved: { role: "$END", prompt: "The review passed. Complete the workflow." }
|
||||
rejected: { role: "developer", prompt: "The reviewer rejected your implementation. Read their feedback and fix the issues: {{{comments}}}" }
|
||||
|
||||
@@ -531,13 +531,25 @@ export async function executeThread(
|
||||
timestamp: nowMs,
|
||||
parentState: options.parentStateHash,
|
||||
},
|
||||
steps: input.steps.map((out, i) => ({
|
||||
role: out.role,
|
||||
contentHash: out.contentHash,
|
||||
meta: out.meta,
|
||||
refs: out.refs,
|
||||
timestamp: replayTs?.[i] ?? prefilled?.[i]?.timestamp ?? nowMs + i,
|
||||
})),
|
||||
steps: await Promise.all(
|
||||
input.steps.map(async (out, i) => {
|
||||
// Resolve content for the last step (most relevant for the next agent).
|
||||
// Earlier steps only carry meta summaries to avoid bloating the prompt.
|
||||
const isLast = i === input.steps.length - 1;
|
||||
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 = {
|
||||
|
||||
@@ -71,6 +71,7 @@ export type RoleStep<M extends RoleMeta> = {
|
||||
role: K;
|
||||
meta: M[K];
|
||||
contentHash: string;
|
||||
content: string | null;
|
||||
refs: string[];
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
@@ -71,7 +71,8 @@ async function buildRoleStepsFromStates<M extends RoleMeta>(
|
||||
cas: CasStore,
|
||||
): Promise<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) {
|
||||
continue;
|
||||
}
|
||||
@@ -79,10 +80,13 @@ async function buildRoleStepsFromStates<M extends RoleMeta>(
|
||||
if (contentParsed === null || contentParsed.kind !== "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({
|
||||
role: st.payload.role,
|
||||
meta: st.payload.meta,
|
||||
contentHash: st.payload.content,
|
||||
content: isLast ? contentParsed.node.payload : null,
|
||||
refs: [...contentParsed.node.refs],
|
||||
timestamp: st.payload.timestamp,
|
||||
} as RoleStep<M>);
|
||||
|
||||
@@ -88,6 +88,7 @@ async function advanceOneRound<M extends RoleMeta>(
|
||||
const step = {
|
||||
role: next,
|
||||
contentHash,
|
||||
content: contentPayload,
|
||||
meta,
|
||||
refs,
|
||||
timestamp: Date.now(),
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("buildAgentPrompt", () => {
|
||||
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 ctx: AgentContext = {
|
||||
start: startTask("user task"),
|
||||
@@ -42,6 +42,7 @@ describe("buildAgentPrompt", () => {
|
||||
{
|
||||
role: "coder",
|
||||
contentHash: onlyHash,
|
||||
content: "Here is my implementation of the feature.",
|
||||
meta: { files: ["a.ts"] },
|
||||
refs: [onlyHash],
|
||||
timestamp: 2,
|
||||
@@ -52,13 +53,39 @@ describe("buildAgentPrompt", () => {
|
||||
expect(text).toContain("## Task");
|
||||
expect(text).toContain("user task");
|
||||
expect(text).toContain("## Step: coder");
|
||||
expect(text).toContain(`ContentHash: ${onlyHash}`);
|
||||
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("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 coderHash = "01HASHCODER0000000000000001";
|
||||
const ctx: AgentContext = {
|
||||
@@ -71,6 +98,7 @@ describe("buildAgentPrompt", () => {
|
||||
{
|
||||
role: "planner",
|
||||
contentHash: plannerHash,
|
||||
content: null,
|
||||
meta: { plan: "short" },
|
||||
refs: [plannerHash],
|
||||
timestamp: 2,
|
||||
@@ -78,6 +106,7 @@ describe("buildAgentPrompt", () => {
|
||||
{
|
||||
role: "coder",
|
||||
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 },
|
||||
refs: [coderHash],
|
||||
timestamp: 3,
|
||||
@@ -90,10 +119,11 @@ describe("buildAgentPrompt", () => {
|
||||
expect(text).toContain("### Step 1: planner");
|
||||
expect(text).toContain('Summary: {"plan":"short"}');
|
||||
expect(text).toContain("## Latest Step: coder");
|
||||
expect(text).toContain(`ContentHash: ${coderHash}`);
|
||||
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("uncaged-workflow thread 01TEST000000000000000000TR");
|
||||
});
|
||||
|
||||
test("parentState null omits Parent Context section", async () => {
|
||||
@@ -125,7 +155,7 @@ describe("buildAgentPrompt", () => {
|
||||
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 hb = "01HASHB00000000000000000001";
|
||||
const hc = "01HASHC00000000000000000001";
|
||||
@@ -139,6 +169,7 @@ describe("buildAgentPrompt", () => {
|
||||
{
|
||||
role: "a",
|
||||
contentHash: ha,
|
||||
content: null,
|
||||
meta: { n: 1 },
|
||||
refs: [ha],
|
||||
timestamp: 2,
|
||||
@@ -146,6 +177,7 @@ describe("buildAgentPrompt", () => {
|
||||
{
|
||||
role: "b",
|
||||
contentHash: hb,
|
||||
content: null,
|
||||
meta: { n: 2 },
|
||||
refs: [hb],
|
||||
timestamp: 3,
|
||||
@@ -153,6 +185,7 @@ describe("buildAgentPrompt", () => {
|
||||
{
|
||||
role: "c",
|
||||
contentHash: hc,
|
||||
content: "Final output from role c",
|
||||
meta: { n: 3 },
|
||||
refs: [hc],
|
||||
timestamp: 4,
|
||||
@@ -162,7 +195,35 @@ describe("buildAgentPrompt", () => {
|
||||
const text = await buildAgentPrompt(ctx);
|
||||
expect(text).toContain('Summary: {"n":1}');
|
||||
expect(text).toContain('Summary: {"n":2}');
|
||||
expect(text).toContain(`ContentHash: ${hc}`);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
+2
-1
@@ -5,11 +5,12 @@
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"uwf": "bun packages/cli-workflow/src/cli.ts",
|
||||
"build": "bunx tsc --build",
|
||||
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
|
||||
"typecheck": "bunx tsc --build",
|
||||
"format": "biome format --write .",
|
||||
"test": "bun run --filter '*' test",
|
||||
"test": "bun run --filter './packages/*' test",
|
||||
"changeset": "bunx changeset",
|
||||
"version": "bunx changeset version",
|
||||
"release": "bun run build && bun test && node scripts/publish-all.mjs"
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
# @uncaged/cli-workflow
|
||||
|
||||
`uwf` CLI — thread lifecycle, workflow registry, CAS inspection, and setup.
|
||||
|
||||
## Overview
|
||||
|
||||
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.
|
||||
|
||||
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`, `@uncaged/workflow-agent-kit`, `@uncaged/workflow-moderator`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`, `commander`, `dotenv`, `yaml`
|
||||
|
||||
## Installation
|
||||
|
||||
Included as the `uwf` binary when you install `@uncaged/cli-workflow`:
|
||||
|
||||
```bash
|
||||
bun add -g @uncaged/cli-workflow
|
||||
# or from the monorepo:
|
||||
bun link packages/cli-workflow
|
||||
```
|
||||
|
||||
## CLI Usage
|
||||
|
||||
### Global options
|
||||
|
||||
```
|
||||
-V, --version Show version
|
||||
--format <json|yaml> Output format (default: json)
|
||||
-h, --help Show help
|
||||
```
|
||||
|
||||
### Thread (Layer 2: Execution Instances)
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf thread start <workflow> -p <prompt>` | Create a thread without executing |
|
||||
| `uwf thread exec <thread-id> [--agent <cmd>] [-c <count>] [--background]` | Execute one or more moderator→agent→extract cycles |
|
||||
| `uwf thread show <thread-id>` | Show thread head pointer |
|
||||
| `uwf thread list [--status <status>] [--after <date>] [--before <date>] [--skip <n>] [--take <n>]` | List threads filtered by status (idle, running, completed, active, or comma-separated), time range (ISO or relative like '7d'), with pagination |
|
||||
| `uwf thread read <thread-id> [--quota N] [--before <hash>] [--start]` | Render thread as readable markdown |
|
||||
|
||||
`thread read`, `step list`, and `step show` work on both active and completed threads.
|
||||
| `uwf thread stop <thread-id>` | Stop background execution (keep thread active) |
|
||||
| `uwf thread cancel <thread-id>` | Cancel thread (stop + archive to history) |
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
uwf thread start solve-issue -p "Fix the login redirect bug"
|
||||
uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||
uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV -c 3 --agent uwf-builtin
|
||||
uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV --background
|
||||
uwf thread list --status running
|
||||
uwf thread list --status active
|
||||
uwf thread list --status idle,completed
|
||||
uwf thread list --after 7d --take 10
|
||||
uwf thread read 01ARZ3NDEKTSV4RRFFQ69G5FAV --quota 8000
|
||||
uwf thread stop 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||
```
|
||||
|
||||
### Step (Layer 3: Single Cycle Results)
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `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 list` | List registered workflows |
|
||||
|
||||
### CAS
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf cas get <hash> [--timestamp]` | Read a CAS node |
|
||||
| `uwf cas put <type-hash> <data>` | Store a node, print hash |
|
||||
| `uwf cas put-text <text>` | Store plain text, print hash |
|
||||
| `uwf cas has <hash>` | Check existence |
|
||||
| `uwf cas refs <hash>` | List direct references |
|
||||
| `uwf cas walk <hash>` | Recursive traversal |
|
||||
| `uwf cas reindex` | Rebuild type index |
|
||||
| `uwf cas schema list` | List registered schemas |
|
||||
| `uwf cas schema get <hash>` | Show a schema |
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
uwf setup
|
||||
uwf setup --provider openai --base-url https://api.openai.com/v1 \
|
||||
--api-key sk-... --model gpt-4o --agent hermes
|
||||
```
|
||||
|
||||
Config: `~/.uncaged/workflow/config.yaml`. API keys: `~/.uncaged/workflow/.env`.
|
||||
|
||||
### Skill
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf skill cli` | Print markdown reference of all uwf commands (for agent skills) |
|
||||
|
||||
### Log
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf log list` | List log files with sizes |
|
||||
| `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 |
|
||||
|
||||
## 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
|
||||
|
||||
```
|
||||
src/
|
||||
├── cli.ts Commander entrypoint, command registration
|
||||
├── format.ts JSON/YAML output formatting
|
||||
├── store.ts CAS store + registry initialization
|
||||
├── validate.ts Workflow YAML validation
|
||||
├── schemas.ts CLI-local schema registration
|
||||
└── commands/
|
||||
├── thread.ts Thread lifecycle and exec
|
||||
├── step.ts Step operations (list/show/read/fork)
|
||||
├── workflow.ts Workflow registry (add/show/list)
|
||||
├── cas.ts CAS inspection and schema ops
|
||||
├── setup.ts Interactive/non-interactive setup
|
||||
├── skill.ts Built-in skill references
|
||||
└── log.ts Process debug log management
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `~/.uncaged/workflow/config.yaml` | Providers, models, default agent |
|
||||
| `~/.uncaged/workflow/.env` | API keys (referenced by `apiKeyEnv` in config) |
|
||||
| `~/.uncaged/workflow/registry.yaml` | Workflow name → CAS hash |
|
||||
| `~/.uncaged/workflow/threads.yaml` | Active thread head pointers |
|
||||
| `~/.uncaged/workflow/cas/` | Content-addressed node storage |
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -62,9 +62,9 @@ const olderEntry = JSON.stringify({
|
||||
|
||||
async function writeLogFiles(): Promise<void> {
|
||||
const logsDir = join(storageRoot, "logs");
|
||||
await writeFile(join(logsDir, "2026-05-20.jsonl"), [entry1, entry2, entry3].join("\n") + "\n");
|
||||
await writeFile(join(logsDir, "2026-05-19.jsonl"), oldEntry + "\n");
|
||||
await writeFile(join(logsDir, "2026-05-18.jsonl"), olderEntry + "\n");
|
||||
await writeFile(join(logsDir, "2026-05-20.jsonl"), `${[entry1, entry2, entry3].join("\n")}\n`);
|
||||
await writeFile(join(logsDir, "2026-05-19.jsonl"), `${oldEntry}\n`);
|
||||
await writeFile(join(logsDir, "2026-05-18.jsonl"), `${olderEntry}\n`);
|
||||
}
|
||||
|
||||
describe("cmdLogList", () => {
|
||||
|
||||
@@ -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,381 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
_discoverAgents,
|
||||
_isBackspace,
|
||||
_isTerminator,
|
||||
_parseWhichOutput,
|
||||
_printModelMenu,
|
||||
_printProviderMenu,
|
||||
_printValidationResult,
|
||||
_resolveModelChoice,
|
||||
_resolveProviderChoice,
|
||||
_searchPathDirs,
|
||||
} from "../commands/setup.js";
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// 1a. _searchPathDirs
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("_searchPathDirs", () => {
|
||||
test("returns empty array for empty PATH", async () => {
|
||||
const result = await _searchPathDirs("");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("finds uwf-hermes in a single dir", async () => {
|
||||
const dir = mkdirSync(join(tmpdir(), `uwf-test-${Date.now()}`), { recursive: true }) as
|
||||
| string
|
||||
| undefined;
|
||||
const actualDir = dir ?? join(tmpdir(), `uwf-test-${Date.now()}`);
|
||||
mkdirSync(actualDir, { recursive: true });
|
||||
const filePath = join(actualDir, "uwf-hermes");
|
||||
writeFileSync(filePath, "#!/bin/sh\n", { mode: 0o755 });
|
||||
const result = await _searchPathDirs(actualDir);
|
||||
expect(result).toContain("uwf-hermes");
|
||||
});
|
||||
|
||||
test("skips non-uwf- prefixed binaries", async () => {
|
||||
const dir = join(tmpdir(), `uwf-test-${Date.now()}-2`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "hermes"), "#!/bin/sh\n", { mode: 0o755 });
|
||||
writeFileSync(join(dir, "uwf-hermes"), "#!/bin/sh\n", { mode: 0o755 });
|
||||
const result = await _searchPathDirs(dir);
|
||||
expect(result).toEqual(["uwf-hermes"]);
|
||||
});
|
||||
|
||||
test("skips entry named exactly 'uwf'", async () => {
|
||||
const dir = join(tmpdir(), `uwf-test-${Date.now()}-3`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "uwf"), "#!/bin/sh\n", { mode: 0o755 });
|
||||
writeFileSync(join(dir, "uwf-hermes"), "#!/bin/sh\n", { mode: 0o755 });
|
||||
const result = await _searchPathDirs(dir);
|
||||
expect(result).toEqual(["uwf-hermes"]);
|
||||
});
|
||||
|
||||
test("skips non-executable files", async () => {
|
||||
const dir = join(tmpdir(), `uwf-test-${Date.now()}-4`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "uwf-foo"), "#!/bin/sh\n", { mode: 0o644 });
|
||||
const result = await _searchPathDirs(dir);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("deduplicates across PATH dirs", async () => {
|
||||
const dir1 = join(tmpdir(), `uwf-test-${Date.now()}-5a`);
|
||||
const dir2 = join(tmpdir(), `uwf-test-${Date.now()}-5b`);
|
||||
mkdirSync(dir1, { recursive: true });
|
||||
mkdirSync(dir2, { recursive: true });
|
||||
writeFileSync(join(dir1, "uwf-hermes"), "#!/bin/sh\n", { mode: 0o755 });
|
||||
writeFileSync(join(dir2, "uwf-hermes"), "#!/bin/sh\n", { mode: 0o755 });
|
||||
const result = await _searchPathDirs(`${dir1}:${dir2}`);
|
||||
expect(result).toEqual(["uwf-hermes"]);
|
||||
});
|
||||
|
||||
test("returns sorted array", async () => {
|
||||
const dir = join(tmpdir(), `uwf-test-${Date.now()}-6`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "uwf-zoo"), "#!/bin/sh\n", { mode: 0o755 });
|
||||
writeFileSync(join(dir, "uwf-alpha"), "#!/bin/sh\n", { mode: 0o755 });
|
||||
writeFileSync(join(dir, "uwf-mid"), "#!/bin/sh\n", { mode: 0o755 });
|
||||
const result = await _searchPathDirs(dir);
|
||||
expect(result).toEqual(["uwf-alpha", "uwf-mid", "uwf-zoo"]);
|
||||
});
|
||||
|
||||
test("skips inaccessible/nonexistent directories silently", async () => {
|
||||
const result = await _searchPathDirs("/nonexistent-dir-xyz-abc-12345");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// 1b. _parseWhichOutput
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("_parseWhichOutput", () => {
|
||||
test("returns empty array for empty string", () => {
|
||||
expect(_parseWhichOutput("")).toEqual([]);
|
||||
});
|
||||
|
||||
test("parses single path", () => {
|
||||
expect(_parseWhichOutput("/usr/local/bin/uwf-hermes")).toEqual(["uwf-hermes"]);
|
||||
});
|
||||
|
||||
test("parses multiple paths", () => {
|
||||
expect(_parseWhichOutput("/usr/local/bin/uwf-hermes\n/usr/bin/uwf-claude-code")).toEqual([
|
||||
"uwf-claude-code",
|
||||
"uwf-hermes",
|
||||
]);
|
||||
});
|
||||
|
||||
test("deduplicates identical basenames from different dirs", () => {
|
||||
expect(_parseWhichOutput("/a/uwf-hermes\n/b/uwf-hermes")).toEqual(["uwf-hermes"]);
|
||||
});
|
||||
|
||||
test("skips blank lines", () => {
|
||||
expect(_parseWhichOutput("/a/uwf-hermes\n\n/b/uwf-cursor")).toEqual([
|
||||
"uwf-cursor",
|
||||
"uwf-hermes",
|
||||
]);
|
||||
});
|
||||
|
||||
test("skips entry named exactly 'uwf'", () => {
|
||||
expect(_parseWhichOutput("/usr/bin/uwf")).toEqual([]);
|
||||
});
|
||||
|
||||
test("skips basenames not starting with uwf-", () => {
|
||||
expect(_parseWhichOutput("/usr/bin/node")).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns sorted array", () => {
|
||||
expect(_parseWhichOutput("/a/uwf-zoo\n/a/uwf-alpha")).toEqual(["uwf-alpha", "uwf-zoo"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// 2a. _isTerminator
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("_isTerminator", () => {
|
||||
test("\\n is a terminator", () => {
|
||||
expect(_isTerminator("\n")).toBe(true);
|
||||
});
|
||||
test("\\r is a terminator", () => {
|
||||
expect(_isTerminator("\r")).toBe(true);
|
||||
});
|
||||
test("\\u0004 (EOT) is a terminator", () => {
|
||||
expect(_isTerminator("")).toBe(true);
|
||||
});
|
||||
test("regular char is not a terminator", () => {
|
||||
expect(_isTerminator("a")).toBe(false);
|
||||
});
|
||||
test("empty string is not a terminator", () => {
|
||||
expect(_isTerminator("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// 2b. _isBackspace
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("_isBackspace", () => {
|
||||
test("\\u007F is a backspace", () => {
|
||||
expect(_isBackspace("")).toBe(true);
|
||||
});
|
||||
test("\\b is a backspace", () => {
|
||||
expect(_isBackspace("\b")).toBe(true);
|
||||
});
|
||||
test("regular char is not a backspace", () => {
|
||||
expect(_isBackspace("x")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// 3a. _printProviderMenu
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("_printProviderMenu", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const providers = [
|
||||
{ name: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1" },
|
||||
{ name: "xai", label: "xAI", baseUrl: "https://api.x.ai/v1" },
|
||||
] as const;
|
||||
|
||||
test("prints correct number of lines (one per provider + custom)", () => {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
lines.push(msg);
|
||||
});
|
||||
_printProviderMenu(providers);
|
||||
// 2 providers + 1 custom = 3 lines
|
||||
expect(lines.length).toBe(3);
|
||||
});
|
||||
|
||||
test("custom option number = providers.length + 1", () => {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
lines.push(msg);
|
||||
});
|
||||
_printProviderMenu(providers);
|
||||
const lastLine = lines[lines.length - 1] ?? "";
|
||||
expect(lastLine).toMatch(/3\)/);
|
||||
});
|
||||
|
||||
test("each provider line contains its label and baseUrl", () => {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
lines.push(msg);
|
||||
});
|
||||
_printProviderMenu(providers);
|
||||
expect(lines[0]).toContain("OpenAI");
|
||||
expect(lines[0]).toContain("https://api.openai.com/v1");
|
||||
expect(lines[1]).toContain("xAI");
|
||||
expect(lines[1]).toContain("https://api.x.ai/v1");
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// 3b. _resolveProviderChoice
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("_resolveProviderChoice", () => {
|
||||
const providers = [
|
||||
{ name: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1" },
|
||||
{ name: "xai", label: "xAI", baseUrl: "https://api.x.ai/v1" },
|
||||
{ name: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/v1" },
|
||||
] as const;
|
||||
|
||||
test("valid index 1 returns first provider", () => {
|
||||
const result = _resolveProviderChoice("1", providers);
|
||||
expect(result).toEqual({ providerName: "openai", baseUrl: "https://api.openai.com/v1" });
|
||||
});
|
||||
|
||||
test("valid index N (last preset) returns last provider", () => {
|
||||
const result = _resolveProviderChoice("3", providers);
|
||||
expect(result).toEqual({ providerName: "deepseek", baseUrl: "https://api.deepseek.com/v1" });
|
||||
});
|
||||
|
||||
test("index providers.length+1 (custom) returns null", () => {
|
||||
const result = _resolveProviderChoice("4", providers);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("non-numeric string returns null", () => {
|
||||
expect(_resolveProviderChoice("abc", providers)).toBeNull();
|
||||
});
|
||||
|
||||
test("0 returns null (out of range)", () => {
|
||||
expect(_resolveProviderChoice("0", providers)).toBeNull();
|
||||
});
|
||||
|
||||
test("N+2 returns null (out of range)", () => {
|
||||
expect(_resolveProviderChoice("5", providers)).toBeNull();
|
||||
});
|
||||
|
||||
test("negative number returns null", () => {
|
||||
expect(_resolveProviderChoice("-1", providers)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// 3c. _resolveModelChoice
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("_resolveModelChoice", () => {
|
||||
test("numeric input within range returns model at that index", () => {
|
||||
expect(_resolveModelChoice("2", ["a", "b", "c"])).toBe("b");
|
||||
});
|
||||
|
||||
test("numeric input out of range returns input as-is", () => {
|
||||
expect(_resolveModelChoice("5", ["a"])).toBe("5");
|
||||
});
|
||||
|
||||
test("non-numeric input returns input as-is", () => {
|
||||
expect(_resolveModelChoice("gpt-4o", ["a", "b"])).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
test("numeric input 1 returns first model", () => {
|
||||
expect(_resolveModelChoice("1", ["alpha", "beta"])).toBe("alpha");
|
||||
});
|
||||
|
||||
test("empty models list with numeric input returns input as-is", () => {
|
||||
expect(_resolveModelChoice("1", [])).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// 3d. _printModelMenu
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("_printModelMenu", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("prints all models — each model name appears in output", () => {
|
||||
const output: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
output.push(msg);
|
||||
});
|
||||
const models = ["model-a", "model-b", "model-c"];
|
||||
_printModelMenu(models, 100);
|
||||
const combined = output.join("\n");
|
||||
for (const m of models) {
|
||||
expect(combined).toContain(m);
|
||||
}
|
||||
});
|
||||
|
||||
test("single column when termCols is very small", () => {
|
||||
const output: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
output.push(msg);
|
||||
});
|
||||
_printModelMenu(["a", "b", "c"], 1);
|
||||
// Each model on its own row → 3 lines
|
||||
expect(output.length).toBe(3);
|
||||
});
|
||||
|
||||
test("wide terminal fits multiple columns", () => {
|
||||
const output: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
output.push(msg);
|
||||
});
|
||||
const models = Array.from({ length: 6 }, (_, i) => `m${i}`);
|
||||
_printModelMenu(models, 200);
|
||||
// With wide terminal and short names, should fit in fewer than 6 rows
|
||||
expect(output.length).toBeLessThan(6);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// 3e. _printValidationResult
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("_printValidationResult", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("ok=true prints success message containing '✓'", () => {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
lines.push(msg);
|
||||
});
|
||||
_printValidationResult({ ok: true, error: null });
|
||||
expect(lines.join("\n")).toContain("✓");
|
||||
});
|
||||
|
||||
test("ok=false prints warning message containing '⚠'", () => {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
lines.push(msg);
|
||||
});
|
||||
_printValidationResult({ ok: false, error: "HTTP 401" });
|
||||
expect(lines.join("\n")).toContain("⚠");
|
||||
});
|
||||
|
||||
test("ok=false includes the error string in output", () => {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||
lines.push(msg);
|
||||
});
|
||||
_printValidationResult({ ok: false, error: "HTTP 401" });
|
||||
expect(lines.join("\n")).toContain("HTTP 401");
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// 4. Regression
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("_discoverAgents regression", () => {
|
||||
test("returns an array (may be empty) — never throws", async () => {
|
||||
const result = await _discoverAgents();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
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.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", () => {
|
||||
const result = runCli(["thread", "step", "--help"]);
|
||||
const result = runCli(["thread", "exec", "--help"]);
|
||||
expect(result.stdout).toContain("--count");
|
||||
expect(result.stdout).toContain("-c");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdThreadStep count logic", () => {
|
||||
describe("cmdThreadExec count logic", () => {
|
||||
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.stderr).toContain("positive integer");
|
||||
});
|
||||
|
||||
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.stderr).toContain("positive integer");
|
||||
});
|
||||
|
||||
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.stderr).toContain("positive integer");
|
||||
});
|
||||
|
||||
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)
|
||||
const result = runCli(["thread", "step", "FAKE_THREAD_ID"]);
|
||||
const result = runCli(["thread", "exec", "FAKE_THREAD_ID"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
// Should NOT contain "positive integer" error — should fail on thread lookup instead
|
||||
expect(result.stderr).not.toContain("positive integer");
|
||||
});
|
||||
|
||||
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);
|
||||
// Should NOT contain "positive integer" error — should fail on thread/storage lookup
|
||||
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 type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { cmdStepList, cmdStepShow } from "../commands/step.js";
|
||||
import {
|
||||
cmdThreadRead,
|
||||
cmdThreadStepDetails,
|
||||
extractLastAssistantContent,
|
||||
THREAD_READ_DEFAULT_QUOTA,
|
||||
} from "../commands/thread.js";
|
||||
import { registerUwfSchemas } from "../schemas.js";
|
||||
import type { UwfStore } from "../store.js";
|
||||
import { saveThreadsIndex } from "../store.js";
|
||||
import { appendThreadHistory, saveThreadsIndex } from "../store.js";
|
||||
|
||||
// ── schemas used in tests ────────────────────────────────────────────────────
|
||||
|
||||
@@ -198,10 +198,10 @@ describe("extractLastAssistantContent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── cmdThreadRead: ### Content section ───────────────────────────────────────
|
||||
// ── cmdThreadRead: <output> section ──────────────────────────────────────────
|
||||
|
||||
describe("cmdThreadRead ### Content section", () => {
|
||||
test("includes ### Content before ### Output when detail has assistant turns", async () => {
|
||||
describe("cmdThreadRead <output> section", () => {
|
||||
test("includes <output> tags when detail has assistant turns", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
const detailSchemas = await registerDetailSchemas(uwf.store);
|
||||
|
||||
@@ -264,17 +264,13 @@ describe("cmdThreadRead ### Content section", () => {
|
||||
|
||||
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");
|
||||
|
||||
const contentIdx = markdown.indexOf("### Content");
|
||||
const outputIdx = markdown.indexOf("### Output");
|
||||
expect(contentIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(outputIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(contentIdx).toBeLessThan(outputIdx);
|
||||
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 workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
@@ -313,14 +309,15 @@ describe("cmdThreadRead ### Content section", () => {
|
||||
|
||||
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).toContain("### Output");
|
||||
});
|
||||
});
|
||||
|
||||
// ── cmdThreadStepDetails ──────────────────────────────────────────────────────
|
||||
// ── cmdStepShow ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("cmdThreadStepDetails", () => {
|
||||
describe("cmdStepShow", () => {
|
||||
test("returns expanded detail node with turns inlined", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
const detailSchemas = await registerDetailSchemas(uwf.store);
|
||||
@@ -368,7 +365,7 @@ describe("cmdThreadStepDetails", () => {
|
||||
agent: "uwf-hermes",
|
||||
});
|
||||
|
||||
const result = await cmdThreadStepDetails(tmpDir, stepHash);
|
||||
const result = await cmdStepShow(tmpDir, stepHash);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
sessionId: "sess42",
|
||||
@@ -387,8 +384,646 @@ describe("cmdThreadStepDetails", () => {
|
||||
content: "done",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("throws when step hash does not exist", async () => {
|
||||
await expect(cmdThreadStepDetails(tmpDir, "nonexistenth0" as CasRef)).rejects.toThrow();
|
||||
// ── cmdThreadRead: <prompt> deduplication ────────────────────────────────────
|
||||
|
||||
describe("cmdThreadRead <prompt> deduplication", () => {
|
||||
async function makeThreadWithRoles(uwf: UwfStore, roles: string[]): Promise<string> {
|
||||
const roleMap: Record<string, unknown> = {};
|
||||
for (const r of [...new Set(roles)]) {
|
||||
roleMap[r] = {
|
||||
description: r,
|
||||
goal: `Goal for ${r}`,
|
||||
capabilities: [],
|
||||
procedure: "Do stuff.",
|
||||
output: "Output.",
|
||||
meta: "placeholder00" as CasRef,
|
||||
};
|
||||
}
|
||||
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
name: "dedup-wf",
|
||||
description: "desc",
|
||||
roles: roleMap,
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
const startHash = await uwf.store.put(uwf.schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "Start",
|
||||
});
|
||||
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
name: "out",
|
||||
description: "",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
let prev: string | null = null;
|
||||
let stepHash = "";
|
||||
for (const role of roles) {
|
||||
stepHash = await uwf.store.put(uwf.schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: prev as CasRef | null,
|
||||
role,
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
prev = stepHash;
|
||||
}
|
||||
return stepHash;
|
||||
}
|
||||
|
||||
test("same consecutive role shows <prompt> once", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
const headHash = await makeThreadWithRoles(uwf, ["writer", "writer"]);
|
||||
const threadId = "01JTEST0000000000000003" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||
const count = (markdown.match(/<prompt>/g) ?? []).length;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
test("different consecutive roles each show <prompt>", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
const headHash = await makeThreadWithRoles(uwf, ["planner", "coder"]);
|
||||
const threadId = "01JTEST0000000000000004" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||
const count = (markdown.match(/<prompt>/g) ?? []).length;
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
test("non-consecutive same role shows <prompt> twice", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
const headHash = await makeThreadWithRoles(uwf, ["roleA", "roleB", "roleA"]);
|
||||
const threadId = "01JTEST0000000000000005" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||
const count = (markdown.match(/<prompt>/g) ?? []).length;
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── cmdThreadRead: showStart / before / quota ─────────────────────────────────
|
||||
|
||||
describe("cmdThreadRead start section / before / quota", () => {
|
||||
async function makeSimpleThread(
|
||||
uwf: UwfStore,
|
||||
roles: string[],
|
||||
): Promise<{ startHash: CasRef; stepHashes: CasRef[] }> {
|
||||
const uniqueRoles = [...new Set(roles)];
|
||||
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
name: "simple-wf",
|
||||
description: "desc",
|
||||
roles: Object.fromEntries(
|
||||
uniqueRoles.map((r) => [
|
||||
r,
|
||||
{
|
||||
description: r,
|
||||
goal: `Goal for ${r}`,
|
||||
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",
|
||||
})) as CasRef;
|
||||
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||
name: "out",
|
||||
description: "",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
|
||||
const stepHashes: CasRef[] = [];
|
||||
let prev: CasRef | null = null;
|
||||
for (const role of roles) {
|
||||
const stepHash = (await uwf.store.put(uwf.schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev,
|
||||
role,
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
})) as CasRef;
|
||||
stepHashes.push(stepHash);
|
||||
prev = stepHash;
|
||||
}
|
||||
return { startHash, stepHashes };
|
||||
}
|
||||
|
||||
test("showStart=true includes # Thread header and ## Task section", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
const { stepHashes } = await makeSimpleThread(uwf, ["roleA"]);
|
||||
const threadId = "01JTEST0000000000000006" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: stepHashes[stepHashes.length - 1]! });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, true);
|
||||
expect(markdown).toContain("# Thread");
|
||||
expect(markdown).toContain("## Task");
|
||||
expect(markdown).toContain("Initial prompt");
|
||||
});
|
||||
|
||||
test("showStart=false with before=null still shows # Thread header (default behavior)", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
const { stepHashes } = await makeSimpleThread(uwf, ["roleA"]);
|
||||
const threadId = "01JTEST0000000000000007" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: stepHashes[stepHashes.length - 1]! });
|
||||
|
||||
// When before=null, the start section is always shown regardless of showStart
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||
expect(markdown).toContain("# Thread");
|
||||
expect(markdown).toContain("## Task");
|
||||
});
|
||||
|
||||
test("before filter: only steps before the given hash appear", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
const { stepHashes } = await makeSimpleThread(uwf, ["roleA", "roleB", "roleC"]);
|
||||
const [_hashA, hashB, hashC] = stepHashes as [CasRef, CasRef, CasRef];
|
||||
const threadId = "01JTEST0000000000000008" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: hashC });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, hashB, false);
|
||||
expect(markdown).toContain("roleA");
|
||||
expect(markdown).not.toContain("roleB");
|
||||
expect(markdown).not.toContain("roleC");
|
||||
});
|
||||
|
||||
test("quota=1 limits output and includes skip hint", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
const { stepHashes } = await makeSimpleThread(uwf, ["roleA", "roleB", "roleC"]);
|
||||
const threadId = "01JTEST000000000000000A" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: stepHashes[stepHashes.length - 1]! });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, 1, null, false);
|
||||
expect(markdown).toContain("earlier step");
|
||||
});
|
||||
|
||||
test("all steps fit in quota: no skip hint", async () => {
|
||||
const uwf = await makeUwfStore(tmpDir);
|
||||
const { stepHashes } = await makeSimpleThread(uwf, ["roleA"]);
|
||||
const threadId = "01JTEST000000000000000B" as ThreadId;
|
||||
await saveThreadsIndex(tmpDir, { [threadId]: stepHashes[0]! });
|
||||
|
||||
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||
expect(markdown).not.toContain("earlier step");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tests that call process.exit must be last ─────────────────────────────────
|
||||
|
||||
describe("cmdStepShow (process.exit tests - must be last)", () => {
|
||||
test("throws when step hash does not exist", async () => {
|
||||
await expect(cmdStepShow(tmpDir, "nonexistenth0" as CasRef)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("before with unknown hash rejects", async () => {
|
||||
const _uwf = await makeUwfStore(tmpDir);
|
||||
const casDir = join(tmpDir, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
const uwfStore: UwfStore = { storageRoot: tmpDir, store, schemas };
|
||||
|
||||
const workflowHash = await uwfStore.store.put(uwfStore.schemas.workflow, {
|
||||
name: "wf2",
|
||||
description: "",
|
||||
roles: {
|
||||
roleA: {
|
||||
description: "r",
|
||||
goal: "g",
|
||||
capabilities: [],
|
||||
procedure: "p",
|
||||
output: "o",
|
||||
meta: "placeholder00" as CasRef,
|
||||
},
|
||||
},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
const startHash = await uwfStore.store.put(uwfStore.schemas.startNode, {
|
||||
workflow: workflowHash,
|
||||
prompt: "p",
|
||||
});
|
||||
const outputHash = await uwfStore.store.put(uwfStore.schemas.workflow, {
|
||||
name: "out",
|
||||
description: "",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
});
|
||||
const stepHash = await uwfStore.store.put(uwfStore.schemas.stepNode, {
|
||||
start: startHash,
|
||||
prev: null,
|
||||
role: "roleA",
|
||||
output: outputHash,
|
||||
detail: null,
|
||||
agent: "uwf-test",
|
||||
});
|
||||
await saveThreadsIndex(tmpDir, { ["01JTEST000000000000000C" as ThreadId]: stepHash as CasRef });
|
||||
|
||||
await expect(
|
||||
cmdThreadRead(
|
||||
tmpDir,
|
||||
"01JTEST000000000000000C" as ThreadId,
|
||||
THREAD_READ_DEFAULT_QUOTA,
|
||||
"unknownhash0" as CasRef,
|
||||
false,
|
||||
),
|
||||
).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,365 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import type { CasRef, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { stringify } from "yaml";
|
||||
import { cmdThreadStart } from "../commands/thread.js";
|
||||
import { registerUwfSchemas } from "../schemas.js";
|
||||
import type { UwfStore } from "../store.js";
|
||||
import { loadWorkflowRegistry, saveWorkflowRegistry } from "../store.js";
|
||||
|
||||
// ── 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 storeWorkflow(uwf: UwfStore, name: string): Promise<CasRef> {
|
||||
const payload: WorkflowPayload = {
|
||||
name,
|
||||
description: "Test workflow",
|
||||
roles: {},
|
||||
graph: {},
|
||||
};
|
||||
return await uwf.store.put(uwf.schemas.workflow, payload);
|
||||
}
|
||||
|
||||
async function createWorkflowYaml(name: string, version: string | null = null): Promise<string> {
|
||||
const payload: WorkflowPayload = {
|
||||
name,
|
||||
description: version !== null ? `Test workflow (${version})` : "Test workflow",
|
||||
roles: {},
|
||||
graph: {},
|
||||
};
|
||||
const yaml = stringify(payload);
|
||||
return yaml;
|
||||
}
|
||||
|
||||
// ── fixture ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let tmpDir: string;
|
||||
let storageRoot: string;
|
||||
let projectRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-wf-resolve-test-"));
|
||||
storageRoot = join(tmpDir, "storage");
|
||||
projectRoot = join(tmpDir, "project");
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
await mkdir(projectRoot, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── Strategy 1: CAS Hash Resolution ───────────────────────────────────────────
|
||||
|
||||
describe("Strategy 1: CAS Hash Resolution", () => {
|
||||
test("should resolve valid 13-char Crockford Base32 hash", async () => {
|
||||
const uwf = await makeUwfStore(storageRoot);
|
||||
const hash = await storeWorkflow(uwf, "test-workflow");
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, hash, "test prompt", projectRoot);
|
||||
|
||||
expect(result.workflow).toBe(hash);
|
||||
expect(result.thread).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
|
||||
});
|
||||
|
||||
test("should fail on invalid hash format (non-Crockford characters)", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
|
||||
await expect(
|
||||
cmdThreadStart(storageRoot, "123456789ABCD", "prompt", projectRoot),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should fail on valid-format hash not present in CAS", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const fakeHash = "0000000000000"; // valid format, doesn't exist
|
||||
|
||||
await expect(cmdThreadStart(storageRoot, fakeHash, "prompt", projectRoot)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should reject 40-char hex hash (legacy format not supported)", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const hexHash = "a".repeat(40);
|
||||
|
||||
await expect(cmdThreadStart(storageRoot, hexHash, "prompt", projectRoot)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Strategy 2: File Path Resolution ──────────────────────────────────────────
|
||||
|
||||
describe("Strategy 2: File Path Resolution", () => {
|
||||
test("should load workflow from absolute file path", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const yamlPath = join(tmpDir, "test-workflow.yaml");
|
||||
await writeFile(yamlPath, await createWorkflowYaml("test-workflow"));
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot);
|
||||
|
||||
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
const uwf = await makeUwfStore(storageRoot);
|
||||
const node = uwf.store.get(result.workflow);
|
||||
expect(node).not.toBeNull();
|
||||
if (node !== null) {
|
||||
expect((node.payload as WorkflowPayload).name).toBe("test-workflow");
|
||||
}
|
||||
});
|
||||
|
||||
test("should load workflow from relative file path", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const yamlPath = "test-workflow.yaml";
|
||||
await writeFile(join(projectRoot, yamlPath), await createWorkflowYaml("test-workflow"));
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot);
|
||||
|
||||
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("should fail when file path does not exist", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
|
||||
await expect(
|
||||
cmdThreadStart(storageRoot, "./nonexistent.yaml", "prompt", projectRoot),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should fail on invalid YAML syntax in file", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const yamlPath = join(tmpDir, "bad-syntax.yaml");
|
||||
await writeFile(yamlPath, "invalid: yaml: : :");
|
||||
|
||||
await expect(cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should fail on valid YAML with invalid WorkflowPayload shape", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const yamlPath = join(tmpDir, "invalid-workflow.yaml");
|
||||
await writeFile(yamlPath, "name: test\n# missing roles and graph");
|
||||
|
||||
await expect(cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should enforce filename matches workflow name", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const yamlPath = join(tmpDir, "solve-issue.yaml");
|
||||
await writeFile(yamlPath, await createWorkflowYaml("wrong-name"));
|
||||
|
||||
await expect(cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Strategy 3: Local Discovery (Parent Traversal) ────────────────────────────
|
||||
|
||||
describe("Strategy 3: Local Discovery", () => {
|
||||
test("should find workflow in current directory .workflow/", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const workflowDir = join(projectRoot, ".workflow");
|
||||
await mkdir(workflowDir, { recursive: true });
|
||||
await writeFile(join(workflowDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue"));
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
|
||||
|
||||
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
const uwf = await makeUwfStore(storageRoot);
|
||||
const node = uwf.store.get(result.workflow);
|
||||
expect(node).not.toBeNull();
|
||||
if (node !== null) {
|
||||
expect((node.payload as WorkflowPayload).name).toBe("solve-issue");
|
||||
}
|
||||
});
|
||||
|
||||
test("should find workflow in parent directory .workflow/", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const workflowDir = join(projectRoot, ".workflow");
|
||||
await mkdir(workflowDir, { recursive: true });
|
||||
await writeFile(join(workflowDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue"));
|
||||
|
||||
const subdir = join(projectRoot, "packages", "cli-workflow", "src");
|
||||
await mkdir(subdir, { recursive: true });
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", subdir);
|
||||
|
||||
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("should stop at filesystem root when traversing", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const deepPath = join(tmpDir, "deep", "path", "that", "does", "not", "have", "workflow");
|
||||
await mkdir(deepPath, { recursive: true });
|
||||
|
||||
await expect(cmdThreadStart(storageRoot, "nonexistent", "prompt", deepPath)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should prefer .workflow/ over .workflows/ directory", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const workflowDir = join(projectRoot, ".workflow");
|
||||
const workflowsDir = join(projectRoot, ".workflows");
|
||||
await mkdir(workflowDir, { recursive: true });
|
||||
await mkdir(workflowsDir, { recursive: true });
|
||||
|
||||
await writeFile(
|
||||
join(workflowDir, "solve-issue.yaml"),
|
||||
await createWorkflowYaml("solve-issue", "1"),
|
||||
);
|
||||
await writeFile(
|
||||
join(workflowsDir, "solve-issue.yaml"),
|
||||
await createWorkflowYaml("solve-issue", "2"),
|
||||
);
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
|
||||
|
||||
const uwf = await makeUwfStore(storageRoot);
|
||||
const node = uwf.store.get(result.workflow);
|
||||
expect(node).not.toBeNull();
|
||||
if (node !== null) {
|
||||
expect((node.payload as WorkflowPayload).description).toBe("Test workflow (1)");
|
||||
}
|
||||
});
|
||||
|
||||
test("should support .yml extension in local discovery", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const workflowDir = join(projectRoot, ".workflow");
|
||||
await mkdir(workflowDir, { recursive: true });
|
||||
await writeFile(join(workflowDir, "solve-issue.yml"), await createWorkflowYaml("solve-issue"));
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
|
||||
|
||||
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Strategy 4: Global Registry Fallback ──────────────────────────────────────
|
||||
|
||||
describe("Strategy 4: Global Registry Resolution", () => {
|
||||
test("should resolve workflow from global registry when not found locally", async () => {
|
||||
const uwf = await makeUwfStore(storageRoot);
|
||||
const hash = await storeWorkflow(uwf, "deploy-pipeline");
|
||||
const registry = await loadWorkflowRegistry(storageRoot);
|
||||
registry["deploy-pipeline"] = hash;
|
||||
await saveWorkflowRegistry(storageRoot, registry);
|
||||
|
||||
const isolatedRoot = join(tmpDir, "isolated");
|
||||
await mkdir(isolatedRoot, { recursive: true });
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, "deploy-pipeline", "prompt", isolatedRoot);
|
||||
|
||||
expect(result.workflow).toBe(hash);
|
||||
});
|
||||
|
||||
test("should fail when workflow not found in any strategy", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
|
||||
await expect(cmdThreadStart(storageRoot, "nonexistent", "prompt", tmpDir)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Strategy Priority Order ───────────────────────────────────────────────────
|
||||
|
||||
describe("Resolution Priority", () => {
|
||||
test("should use explicit file path over local discovery", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
|
||||
// Setup: Create workflow in .workflow/ AND as explicit file
|
||||
const workflowDir = join(projectRoot, ".workflow");
|
||||
await mkdir(workflowDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(workflowDir, "solve-issue.yaml"),
|
||||
await createWorkflowYaml("solve-issue", "discovery"),
|
||||
);
|
||||
|
||||
const explicitPath = join(projectRoot, "custom-solve-issue.yaml");
|
||||
await writeFile(explicitPath, await createWorkflowYaml("custom-solve-issue", "explicit"));
|
||||
|
||||
// Execute with explicit path
|
||||
const result = await cmdThreadStart(storageRoot, explicitPath, "prompt", projectRoot);
|
||||
|
||||
const uwf = await makeUwfStore(storageRoot);
|
||||
const node = uwf.store.get(result.workflow);
|
||||
expect(node).not.toBeNull();
|
||||
if (node !== null) {
|
||||
expect((node.payload as WorkflowPayload).description).toBe("Test workflow (explicit)");
|
||||
}
|
||||
});
|
||||
|
||||
test("should use local discovery over global registry", async () => {
|
||||
const uwf = await makeUwfStore(storageRoot);
|
||||
|
||||
// Setup: Register globally
|
||||
const globalHash = await storeWorkflow(uwf, "solve-issue");
|
||||
const registry = await loadWorkflowRegistry(storageRoot);
|
||||
registry["solve-issue"] = globalHash;
|
||||
await saveWorkflowRegistry(storageRoot, registry);
|
||||
|
||||
// Setup: Create local .workflow/
|
||||
const workflowDir = join(projectRoot, ".workflow");
|
||||
await mkdir(workflowDir, { recursive: true });
|
||||
const localYaml = await createWorkflowYaml("solve-issue", "local");
|
||||
await writeFile(join(workflowDir, "solve-issue.yaml"), localYaml);
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
|
||||
|
||||
const uwf2 = await makeUwfStore(storageRoot);
|
||||
const node = uwf2.store.get(result.workflow);
|
||||
expect(node).not.toBeNull();
|
||||
if (node !== null) {
|
||||
expect((node.payload as WorkflowPayload).description).toBe("Test workflow (local)");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edge Cases ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
test("should treat '13-char-string.yaml' as file path, not CAS hash", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const fileName = "0123456789ABC.yaml"; // 13 chars + .yaml
|
||||
await writeFile(join(projectRoot, fileName), await createWorkflowYaml("0123456789ABC"));
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, fileName, "prompt", projectRoot);
|
||||
|
||||
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("should handle workflow names containing slashes as file paths", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const filePath = "subdir/solve-issue.yaml";
|
||||
const fullPath = join(projectRoot, filePath);
|
||||
await mkdir(join(projectRoot, "subdir"), { recursive: true });
|
||||
await writeFile(fullPath, await createWorkflowYaml("solve-issue"));
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, filePath, "prompt", projectRoot);
|
||||
|
||||
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("should handle absolute paths correctly", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const absPath = join(tmpDir, "abs-workflow.yaml");
|
||||
await writeFile(absPath, await createWorkflowYaml("abs-workflow"));
|
||||
|
||||
const result = await cmdThreadStart(storageRoot, absPath, "prompt", projectRoot);
|
||||
|
||||
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
|
||||
});
|
||||
|
||||
test("should fail on empty workflow ID", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
|
||||
await expect(cmdThreadStart(storageRoot, "", "prompt", projectRoot)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should fail on whitespace-only workflow ID", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
|
||||
await expect(cmdThreadStart(storageRoot, " ", "prompt", projectRoot)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
import type { ThreadId } from "@uncaged/workflow-protocol";
|
||||
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||
import { Command } from "commander";
|
||||
import { stringify as yamlStringify } from "yaml";
|
||||
import {
|
||||
cmdCasGet,
|
||||
cmdCasHas,
|
||||
@@ -17,19 +16,20 @@ import {
|
||||
import { cmdLogClean, cmdLogList, cmdLogShow } from "./commands/log.js";
|
||||
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
|
||||
import { cmdSkillCli } from "./commands/skill.js";
|
||||
import { cmdStepFork, cmdStepList, cmdStepRead, cmdStepShow } from "./commands/step.js";
|
||||
import {
|
||||
cmdThreadFork,
|
||||
cmdThreadKill,
|
||||
cmdThreadCancel,
|
||||
cmdThreadExec,
|
||||
cmdThreadList,
|
||||
cmdThreadRead,
|
||||
cmdThreadShow,
|
||||
cmdThreadStart,
|
||||
cmdThreadStep,
|
||||
cmdThreadStepDetails,
|
||||
cmdThreadSteps,
|
||||
cmdThreadStop,
|
||||
THREAD_READ_DEFAULT_QUOTA,
|
||||
type ThreadStatus,
|
||||
} 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 { resolveStorageRoot } from "./store.js";
|
||||
|
||||
@@ -52,20 +52,27 @@ const program = new Command();
|
||||
const pkg = await import("../package.json", { with: { type: "json" } });
|
||||
program
|
||||
.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");
|
||||
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
|
||||
.command("put")
|
||||
.command("add")
|
||||
.description("Register a workflow from YAML")
|
||||
.argument("<file>", "Workflow YAML file")
|
||||
.action((file: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdWorkflowPut(storageRoot, file);
|
||||
const result = await cmdWorkflowAdd(storageRoot, file);
|
||||
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
|
||||
.command("start")
|
||||
@@ -109,24 +116,46 @@ thread
|
||||
});
|
||||
|
||||
thread
|
||||
.command("step")
|
||||
.command("exec")
|
||||
.description("Execute one or more steps")
|
||||
.argument("<thread-id>", "Thread ULID")
|
||||
.option("--agent <cmd>", "Override agent command")
|
||||
.option("-c, --count <number>", "Number of steps to run (default: 1)")
|
||||
.action((threadId: string, opts: { agent: string | undefined; count: string | undefined }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const agentOverride = opts.agent ?? null;
|
||||
const count = opts.count !== undefined ? Number(opts.count) : 1;
|
||||
const results = await cmdThreadStep(storageRoot, threadId, agentOverride, count);
|
||||
if (results.length === 1) {
|
||||
writeOutput(results[0]);
|
||||
} else {
|
||||
writeOutput(results);
|
||||
}
|
||||
});
|
||||
});
|
||||
.option("--background", "Run in background and return immediately")
|
||||
.option("--_background-worker", "Internal flag for background worker process", false)
|
||||
.action(
|
||||
(
|
||||
threadId: string,
|
||||
opts: {
|
||||
agent: string | undefined;
|
||||
count: string | undefined;
|
||||
background: boolean;
|
||||
_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
|
||||
.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
|
||||
.command("list")
|
||||
.description("List active threads")
|
||||
.option("--all", "Include archived threads")
|
||||
.action((opts: { all: boolean }) => {
|
||||
.description("List threads")
|
||||
.option(
|
||||
"--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();
|
||||
runAction(async () => {
|
||||
const result = await cmdThreadList(storageRoot, opts.all);
|
||||
const result = await cmdThreadStop(storageRoot, threadId);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
thread
|
||||
.command("kill")
|
||||
.description("Terminate and archive a thread")
|
||||
.command("cancel")
|
||||
.description("Cancel a thread (stop execution and move to history)")
|
||||
.argument("<thread-id>", "Thread ULID")
|
||||
.action((threadId: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdThreadKill(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);
|
||||
const result = await cmdThreadCancel(storageRoot, threadId);
|
||||
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")
|
||||
.description("Fork a thread from a specific step")
|
||||
.argument("<step-hash>", "CAS hash of the StartNode or StepNode to fork from")
|
||||
.action((stepHash: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdThreadFork(storageRoot, stepHash);
|
||||
const result = await cmdStepFork(storageRoot, stepHash as CasRef);
|
||||
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
|
||||
.command("step-details")
|
||||
.description("Dump the full detail node of a step as YAML")
|
||||
.argument("<step-hash>", "CAS hash of the StepNode")
|
||||
.action((stepHash: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const detail = await cmdThreadStepDetails(storageRoot, stepHash);
|
||||
process.stdout.write(yamlStringify(detail));
|
||||
});
|
||||
.description("[DEPRECATED] Use 'step show' instead")
|
||||
.argument("<step-hash>", "Step hash")
|
||||
.action(() => {
|
||||
process.stderr.write(`Error: Command 'thread step-details' has been removed.
|
||||
Use 'step show' instead.
|
||||
|
||||
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");
|
||||
@@ -321,7 +565,11 @@ cas
|
||||
.action((hash: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
writeOutput(await cmdCasHas(storageRoot, hash));
|
||||
const result = await cmdCasHas(storageRoot, hash);
|
||||
writeOutput(result);
|
||||
if (!result.exists) {
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
@@ -137,6 +137,182 @@ function apiKeyEnvName(providerName: string): string {
|
||||
return `${providerName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Extracted helpers — _discoverAgents
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scans directories from a PATH string for uwf-* executables.
|
||||
*/
|
||||
export async function _searchPathDirs(pathEnv: string): Promise<string[]> {
|
||||
if (!pathEnv) return [];
|
||||
const dirs = pathEnv.split(":").filter((d) => d.length > 0);
|
||||
const agents = new Set<string>();
|
||||
for (const dir of dirs) {
|
||||
_scanDirForAgents(dir, agents);
|
||||
}
|
||||
return Array.from(agents).sort();
|
||||
}
|
||||
|
||||
function _scanDirForAgents(dir: string, agents: Set<string>): void {
|
||||
try {
|
||||
if (!existsSync(dir)) return;
|
||||
const entries = readdirSync(dir);
|
||||
for (const entry of entries) {
|
||||
if (!entry.startsWith("uwf-") || entry === "uwf") continue;
|
||||
if (_isExecutableFile(join(dir, entry))) {
|
||||
agents.add(entry);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip inaccessible directories
|
||||
}
|
||||
}
|
||||
|
||||
function _isExecutableFile(fullPath: string): boolean {
|
||||
try {
|
||||
const s = statSync(fullPath);
|
||||
return s.isFile() && (s.mode & 0o111) !== 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the stdout of `which -a` into sorted unique basenames.
|
||||
*/
|
||||
export function _parseWhichOutput(text: string): string[] {
|
||||
if (!text) return [];
|
||||
const agents = new Set<string>();
|
||||
for (const line of text.trim().split("\n")) {
|
||||
if (!line) continue;
|
||||
const basename = line.split("/").pop() ?? "";
|
||||
if (basename.startsWith("uwf-") && basename !== "uwf") {
|
||||
agents.add(basename);
|
||||
}
|
||||
}
|
||||
return Array.from(agents).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover uwf-* agent binaries in PATH.
|
||||
* Returns sorted list of binary names (e.g., ["uwf-hermes", "uwf-claude-code"]).
|
||||
*/
|
||||
export async function _discoverAgents(): Promise<string[]> {
|
||||
try {
|
||||
const agents = await _tryWhichDiscovery();
|
||||
if (agents !== null) return agents;
|
||||
return await _searchPathDirs(process.env.PATH ?? "");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function _tryWhichDiscovery(): Promise<string[] | null> {
|
||||
try {
|
||||
const proc = Bun.spawn(["which", "-a", "uwf-hermes", "uwf-claude-code", "uwf-cursor"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const text = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
if (proc.exitCode !== 0) return null;
|
||||
return _parseWhichOutput(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Extracted helpers — onData closure (promptSecret)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Returns true for newline, carriage return, or EOF (EOT). */
|
||||
export function _isTerminator(c: string): boolean {
|
||||
return c === "\n" || c === "\r" || c === "";
|
||||
}
|
||||
|
||||
/** Returns true for DEL or backspace. */
|
||||
export function _isBackspace(c: string): boolean {
|
||||
return c === "" || c === "\b";
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Extracted helpers — cmdSetupInteractive
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ProviderEntry = { name: string; label: string; baseUrl: string };
|
||||
|
||||
/** Prints the numbered provider list and custom option to stdout. */
|
||||
export function _printProviderMenu(providers: readonly ProviderEntry[]): void {
|
||||
const numWidth = String(providers.length + 1).length;
|
||||
for (let i = 0; i < providers.length; i++) {
|
||||
const p = providers[i];
|
||||
if (!p) continue;
|
||||
const num = String(i + 1).padStart(numWidth);
|
||||
console.log(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
|
||||
}
|
||||
const customNum = String(providers.length + 1).padStart(numWidth);
|
||||
console.log(` ${customNum}) Custom (enter name and URL manually)\n`);
|
||||
}
|
||||
|
||||
/** Resolves a numeric choice string to a preset provider, or null for custom/invalid. */
|
||||
export function _resolveProviderChoice(
|
||||
choice: string,
|
||||
providers: readonly ProviderEntry[],
|
||||
): { providerName: string; baseUrl: string } | null {
|
||||
const n = Number.parseInt(choice, 10);
|
||||
if (Number.isNaN(n) || n < 1 || n > providers.length) return null;
|
||||
const p = providers[n - 1];
|
||||
if (!p) return null;
|
||||
return { providerName: p.name, baseUrl: p.baseUrl };
|
||||
}
|
||||
|
||||
/** Resolves numeric index or literal model name to a model string. */
|
||||
export function _resolveModelChoice(input: string, models: string[]): string {
|
||||
const n = Number.parseInt(input, 10);
|
||||
if (!Number.isNaN(n) && n >= 1 && n <= models.length) {
|
||||
return models[n - 1] ?? input;
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
/** Prints the multi-column model list to stdout. */
|
||||
export function _printModelMenu(models: string[], termCols: number): void {
|
||||
const nw = String(models.length).length;
|
||||
const maxLen = models.reduce((m, s) => Math.max(m, s.length), 0);
|
||||
const colWidth = nw + 2 + maxLen + 4;
|
||||
const cols = Math.max(1, Math.floor(termCols / colWidth));
|
||||
const rows = Math.ceil(models.length / cols);
|
||||
for (let r = 0; r < rows; r++) {
|
||||
let line = "";
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const idx = c * rows + r;
|
||||
if (idx >= models.length) break;
|
||||
const num = String(idx + 1).padStart(nw);
|
||||
const name = (models[idx] ?? "").padEnd(maxLen);
|
||||
line += ` ${num}) ${name} `;
|
||||
}
|
||||
console.log(line.trimEnd());
|
||||
}
|
||||
}
|
||||
|
||||
type ValidationResult = { ok: boolean; error: string | null };
|
||||
|
||||
/** Prints the model validation result to stdout. */
|
||||
export function _printValidationResult(validation: ValidationResult): void {
|
||||
if (validation.ok) {
|
||||
console.log("✓ Model verified — connection successful.\n");
|
||||
} else {
|
||||
console.log(`\n⚠ Warning: Could not reach model — ${validation.error}`);
|
||||
console.log(
|
||||
" Config saved, but you may want to try a different model or check your API key.\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Merge setup args into config.yaml structure. Non-destructive — preserves existing entries.
|
||||
*/
|
||||
@@ -212,6 +388,46 @@ export async function cmdSetup(args: SetupArgs): Promise<Record<string, unknown>
|
||||
};
|
||||
}
|
||||
|
||||
type SecretState = {
|
||||
buf: string;
|
||||
rawWasSet: boolean;
|
||||
resolve: (value: string) => void;
|
||||
onData: (chunk: string) => void;
|
||||
};
|
||||
|
||||
function _handleSecretTerminator(state: SecretState): void {
|
||||
if (process.stdin.isTTY) process.stdin.setRawMode(state.rawWasSet);
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", state.onData);
|
||||
process.stdout.write("\n");
|
||||
state.resolve(state.buf.trim());
|
||||
}
|
||||
|
||||
function _handleSecretBackspace(state: SecretState): void {
|
||||
if (state.buf.length > 0) {
|
||||
state.buf = state.buf.slice(0, -1);
|
||||
process.stdout.write("\b \b");
|
||||
}
|
||||
}
|
||||
|
||||
function _handleSecretChar(c: string, state: SecretState): boolean {
|
||||
if (_isTerminator(c)) {
|
||||
_handleSecretTerminator(state);
|
||||
return true;
|
||||
}
|
||||
if (_isBackspace(c)) {
|
||||
_handleSecretBackspace(state);
|
||||
return false;
|
||||
}
|
||||
if (c === "") {
|
||||
if (process.stdin.isTTY) process.stdin.setRawMode(state.rawWasSet);
|
||||
process.exit(130);
|
||||
}
|
||||
state.buf += c;
|
||||
process.stdout.write("*");
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Read a line with terminal echo disabled (for secrets). */
|
||||
async function promptSecret(label: string): Promise<string> {
|
||||
process.stdout.write(label);
|
||||
@@ -223,33 +439,13 @@ async function promptSecret(label: string): Promise<string> {
|
||||
process.stdin.resume();
|
||||
process.stdin.setEncoding("utf8");
|
||||
|
||||
let buf = "";
|
||||
const onData = (chunk: string) => {
|
||||
const state: SecretState = { buf: "", rawWasSet, resolve, onData: () => {} };
|
||||
state.onData = (chunk: string) => {
|
||||
for (const c of chunk.toString()) {
|
||||
if (c === "\n" || c === "\r" || c === "\u0004") {
|
||||
if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet);
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", onData);
|
||||
process.stdout.write("\n");
|
||||
resolve(buf.trim());
|
||||
return;
|
||||
}
|
||||
if (c === "\u007F" || c === "\b") {
|
||||
if (buf.length > 0) {
|
||||
buf = buf.slice(0, -1);
|
||||
process.stdout.write("\b \b");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c === "\u0003") {
|
||||
if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet);
|
||||
process.exit(130);
|
||||
}
|
||||
buf += c;
|
||||
process.stdout.write("*");
|
||||
if (_handleSecretChar(c, state)) return;
|
||||
}
|
||||
};
|
||||
process.stdin.on("data", onData);
|
||||
process.stdin.on("data", state.onData);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -275,6 +471,56 @@ async function fetchModels(baseUrl: string, apiKey: string): Promise<string[]> {
|
||||
}
|
||||
}
|
||||
|
||||
async function _promptProviderSelection(
|
||||
rl: ReturnType<typeof createInterface>,
|
||||
): Promise<{ providerName: string; baseUrl: string }> {
|
||||
console.log("Select a provider:\n");
|
||||
_printProviderMenu(PRESET_PROVIDERS);
|
||||
|
||||
const choice = (await rl.question(`Choose [1-${PRESET_PROVIDERS.length + 1}]: `)).trim();
|
||||
const choiceNum = Number.parseInt(choice, 10);
|
||||
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > PRESET_PROVIDERS.length + 1) {
|
||||
throw new Error(`Invalid choice: ${choice}`);
|
||||
}
|
||||
|
||||
const preset = _resolveProviderChoice(choice, PRESET_PROVIDERS);
|
||||
if (preset) {
|
||||
const selected = PRESET_PROVIDERS[choiceNum - 1];
|
||||
if (selected) {
|
||||
console.log(`\n → ${selected.label} (${selected.baseUrl})\n`);
|
||||
}
|
||||
return preset;
|
||||
}
|
||||
|
||||
const providerName = (await rl.question("Provider name (e.g. my-proxy): ")).trim();
|
||||
if (!providerName) throw new Error("Provider name required");
|
||||
const baseUrl = (await rl.question("OpenAI-compatible API base URL: ")).trim();
|
||||
if (!baseUrl) throw new Error("Base URL required");
|
||||
return { providerName, baseUrl };
|
||||
}
|
||||
|
||||
async function _promptModelSelection(
|
||||
rl: ReturnType<typeof createInterface>,
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
): Promise<string> {
|
||||
console.log("\nFetching available models...");
|
||||
const models = await fetchModels(baseUrl, apiKey);
|
||||
|
||||
if (models.length === 0) {
|
||||
console.log("Could not fetch models. Enter model name manually.");
|
||||
const model = (await rl.question("Default model (e.g. qwen-plus, gpt-4o): ")).trim();
|
||||
if (!model) throw new Error("Model required");
|
||||
return model;
|
||||
}
|
||||
console.log(`\nAvailable models (${models.length}):\n`);
|
||||
_printModelMenu(models, process.stdout.columns || 100);
|
||||
console.log(`\nChoose a number, or type a model name directly.`);
|
||||
const modelInput = (await rl.question(`Default model [1-${models.length}]: `)).trim();
|
||||
if (!modelInput) throw new Error("Model required");
|
||||
return _resolveModelChoice(modelInput, models);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive setup — prompts user for provider, API key, model.
|
||||
*/
|
||||
@@ -284,39 +530,7 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
|
||||
try {
|
||||
console.log("Configure LLM provider for uwf workflow agents.\n");
|
||||
|
||||
// 1. Provider selection
|
||||
const numWidth = String(PRESET_PROVIDERS.length + 1).length;
|
||||
console.log("Select a provider:\n");
|
||||
for (let i = 0; i < PRESET_PROVIDERS.length; i++) {
|
||||
const p = PRESET_PROVIDERS[i];
|
||||
if (!p) continue;
|
||||
const num = String(i + 1).padStart(numWidth);
|
||||
console.log(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
|
||||
}
|
||||
const customNum = String(PRESET_PROVIDERS.length + 1).padStart(numWidth);
|
||||
console.log(` ${customNum}) Custom (enter name and URL manually)\n`);
|
||||
|
||||
const choice = (await rl.question(`Choose [1-${PRESET_PROVIDERS.length + 1}]: `)).trim();
|
||||
const choiceNum = Number.parseInt(choice, 10);
|
||||
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > PRESET_PROVIDERS.length + 1) {
|
||||
throw new Error(`Invalid choice: ${choice}`);
|
||||
}
|
||||
|
||||
let providerName: string;
|
||||
let baseUrl: string;
|
||||
|
||||
if (choiceNum <= PRESET_PROVIDERS.length) {
|
||||
const selected = PRESET_PROVIDERS[choiceNum - 1];
|
||||
if (!selected) throw new Error("Invalid selection");
|
||||
providerName = selected.name;
|
||||
baseUrl = selected.baseUrl;
|
||||
console.log(`\n → ${selected.label} (${selected.baseUrl})\n`);
|
||||
} else {
|
||||
providerName = (await rl.question("Provider name (e.g. my-proxy): ")).trim();
|
||||
if (!providerName) throw new Error("Provider name required");
|
||||
baseUrl = (await rl.question("OpenAI-compatible API base URL: ")).trim();
|
||||
if (!baseUrl) throw new Error("Base URL required");
|
||||
}
|
||||
const { providerName, baseUrl } = await _promptProviderSelection(rl);
|
||||
|
||||
// 2. API key
|
||||
rl.close();
|
||||
@@ -325,47 +539,8 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
|
||||
|
||||
// 3. Model selection
|
||||
const rl2 = createInterface({ input, output });
|
||||
console.log("\nFetching available models...");
|
||||
const models = await fetchModels(baseUrl, apiKey);
|
||||
|
||||
let model: string;
|
||||
if (models.length > 0) {
|
||||
console.log(`\nAvailable models (${models.length}):\n`);
|
||||
const nw = String(models.length).length;
|
||||
// Multi-column layout
|
||||
const maxLen = models.reduce((m, s) => Math.max(m, s.length), 0);
|
||||
const colWidth = nw + 2 + maxLen + 4; // " N) name "
|
||||
const termCols = process.stdout.columns || 100;
|
||||
const cols = Math.max(1, Math.floor(termCols / colWidth));
|
||||
const rows = Math.ceil(models.length / cols);
|
||||
for (let r = 0; r < rows; r++) {
|
||||
let line = "";
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const idx = c * rows + r;
|
||||
if (idx >= models.length) break;
|
||||
const num = String(idx + 1).padStart(nw);
|
||||
const name = (models[idx] ?? "").padEnd(maxLen);
|
||||
line += ` ${num}) ${name} `;
|
||||
}
|
||||
console.log(line.trimEnd());
|
||||
}
|
||||
console.log(`\nChoose a number, or type a model name directly.`);
|
||||
const modelInput = (await rl2.question(`Default model [1-${models.length}]: `)).trim();
|
||||
if (!modelInput) throw new Error("Model required");
|
||||
const modelNum = Number.parseInt(modelInput, 10);
|
||||
if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) {
|
||||
model = models[modelNum - 1] ?? modelInput;
|
||||
} else {
|
||||
model = modelInput;
|
||||
}
|
||||
} else {
|
||||
console.log("Could not fetch models. Enter model name manually.");
|
||||
model = (await rl2.question("Default model (e.g. qwen-plus, gpt-4o): ")).trim();
|
||||
if (!model) throw new Error("Model required");
|
||||
}
|
||||
|
||||
const model = await _promptModelSelection(rl2, baseUrl, apiKey);
|
||||
rl2.close();
|
||||
|
||||
console.log(` → ${providerName}/${model}\n`);
|
||||
|
||||
const setupResult = await cmdSetup({
|
||||
@@ -378,17 +553,8 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
|
||||
|
||||
// Show validation result
|
||||
if (setupResult.validation && typeof setupResult.validation === "object") {
|
||||
const v = setupResult.validation as { ok: boolean; error?: string };
|
||||
if (v.ok) {
|
||||
console.log("✓ Model verified — connection successful.\n");
|
||||
} else {
|
||||
console.log(`\n⚠ Warning: Could not reach model — ${v.error}`);
|
||||
console.log(
|
||||
" Config saved, but you may want to try a different model or check your API key.\n",
|
||||
);
|
||||
}
|
||||
_printValidationResult(setupResult.validation as ValidationResult);
|
||||
}
|
||||
|
||||
console.log("Setup complete! Get started:\n");
|
||||
console.log(" uwf workflow put <workflow.yaml> Register a workflow");
|
||||
console.log(' uwf thread start <name> -p "..." Start a thread');
|
||||
|
||||
@@ -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,256 @@
|
||||
import type { BootstrapCapableStore } from "@uncaged/json-cas";
|
||||
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";
|
||||
|
||||
type TurnData = {
|
||||
index: number;
|
||||
content: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and validate step detail node from CAS store
|
||||
*/
|
||||
function loadStepDetail(store: BootstrapCapableStore, detailRef: CasRef): Record<string, unknown> {
|
||||
const detailNode = store.get(detailRef);
|
||||
if (detailNode === null) {
|
||||
fail(`detail node not found: ${detailRef}`);
|
||||
}
|
||||
return detailNode.payload as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all turn nodes from CAS store and extract content
|
||||
*/
|
||||
function loadTurnData(store: BootstrapCapableStore, turns: unknown): TurnData[] {
|
||||
if (!Array.isArray(turns) || turns.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const turnData: TurnData[] = [];
|
||||
for (const turnRef of turns) {
|
||||
if (typeof turnRef !== "string") {
|
||||
continue;
|
||||
}
|
||||
const turnNode = store.get(turnRef as CasRef);
|
||||
if (turnNode === null) {
|
||||
continue;
|
||||
}
|
||||
const turn = turnNode.payload as Record<string, unknown>;
|
||||
if (typeof turn.content === "string") {
|
||||
turnData.push({
|
||||
index: typeof turn.index === "number" ? turn.index : turnData.length,
|
||||
content: turn.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
return turnData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select turns that fit within quota, working backwards from most recent
|
||||
*/
|
||||
function selectTurnsForQuota(turnData: TurnData[], availableQuota: number): TurnData[] {
|
||||
const selectedTurns: TurnData[] = [];
|
||||
let totalChars = 0;
|
||||
|
||||
for (let i = turnData.length - 1; i >= 0; i--) {
|
||||
const turn = turnData[i];
|
||||
if (turn === undefined) continue;
|
||||
|
||||
const turnHeader = `## Turn ${turn.index + 1}\n\n`;
|
||||
const turnBlock = turnHeader + turn.content;
|
||||
const separatorCost = selectedTurns.length > 0 ? 2 : 0;
|
||||
const addCost = turnBlock.length + separatorCost;
|
||||
|
||||
if (totalChars + addCost > availableQuota && selectedTurns.length > 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
selectedTurns.unshift(turn);
|
||||
totalChars += addCost;
|
||||
}
|
||||
|
||||
return selectedTurns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble final markdown output from header and selected turns
|
||||
*/
|
||||
function formatStepMarkdown(
|
||||
stepHash: CasRef,
|
||||
role: string,
|
||||
agent: string,
|
||||
turnData: TurnData[],
|
||||
selectedTurns: TurnData[],
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(`# Step ${stepHash}`);
|
||||
parts.push("");
|
||||
parts.push(`**Role:** ${role}`);
|
||||
parts.push(`**Agent:** ${agent}`);
|
||||
|
||||
if (selectedTurns.length === 0) {
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
const skippedCount = turnData.length - selectedTurns.length;
|
||||
if (skippedCount > 0) {
|
||||
parts.push("");
|
||||
parts.push(`_[Earlier turns omitted due to quota. Use --quota to increase.]_`);
|
||||
}
|
||||
|
||||
for (const turn of selectedTurns) {
|
||||
parts.push("");
|
||||
parts.push(`## Turn ${turn.index + 1}`);
|
||||
parts.push("");
|
||||
parts.push(turn.content);
|
||||
}
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a step's agent turns as human-readable markdown with quota enforcement
|
||||
*/
|
||||
export async function cmdStepRead(
|
||||
storageRoot: string,
|
||||
stepHash: CasRef,
|
||||
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;
|
||||
|
||||
if (payload.detail === null) {
|
||||
return formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
|
||||
}
|
||||
|
||||
const detail = loadStepDetail(uwf.store, payload.detail);
|
||||
const turnData = loadTurnData(uwf.store, detail.turns);
|
||||
|
||||
if (turnData.length === 0) {
|
||||
return formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
|
||||
}
|
||||
|
||||
const headerSection = formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
|
||||
const BUFFER = 200;
|
||||
const availableQuota = quota - headerSection.length - BUFFER;
|
||||
const selectedTurns = selectTurnsForQuota(turnData, availableQuota);
|
||||
|
||||
return formatStepMarkdown(stepHash, payload.role, payload.agent, turnData, selectedTurns);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,12 +2,7 @@ import { readFile } from "node:fs/promises";
|
||||
|
||||
import type { JSONSchema } from "@uncaged/json-cas";
|
||||
import { putSchema, validate } from "@uncaged/json-cas";
|
||||
import type {
|
||||
CasRef,
|
||||
RoleDefinition,
|
||||
Transition,
|
||||
WorkflowPayload,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import type { CasRef, RoleDefinition, Target, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import { parse } from "yaml";
|
||||
|
||||
import {
|
||||
@@ -29,7 +24,7 @@ export type WorkflowListEntry = {
|
||||
origin: WorkflowOrigin;
|
||||
};
|
||||
|
||||
export type WorkflowPutOutput = {
|
||||
export type WorkflowAddOutput = {
|
||||
name: string;
|
||||
hash: CasRef;
|
||||
};
|
||||
@@ -51,20 +46,23 @@ function isJsonSchema(value: unknown): value is JSONSchema {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Normalize graph transitions: ensure condition is null (not undefined) for fallback entries. */
|
||||
function normalizeGraph(graph: Record<string, Transition[]>): Record<string, Transition[]> {
|
||||
const result: Record<string, Transition[]> = {};
|
||||
for (const [node, transitions] of Object.entries(graph)) {
|
||||
result[node] = transitions.map((t) => {
|
||||
if (typeof t.prompt !== "string" || t.prompt.trim() === "") {
|
||||
fail(`graph[${node}] transition to "${t.role}": prompt is required (non-empty string)`);
|
||||
/** Normalize graph: validate each status → target mapping. */
|
||||
function normalizeGraph(
|
||||
graph: Record<string, Record<string, Target>>,
|
||||
): Record<string, Record<string, Target>> {
|
||||
const result: Record<string, Record<string, Target>> = {};
|
||||
for (const [node, statusMap] of Object.entries(graph)) {
|
||||
const normalized: Record<string, Target> = {};
|
||||
for (const [status, target] of Object.entries(statusMap)) {
|
||||
if (typeof target.prompt !== "string" || target.prompt.trim() === "") {
|
||||
fail(`graph[${node}][${status}] → "${target.role}": prompt is required (non-empty string)`);
|
||||
}
|
||||
return {
|
||||
role: t.role,
|
||||
condition: t.condition ?? null,
|
||||
prompt: t.prompt,
|
||||
normalized[status] = {
|
||||
role: target.role,
|
||||
prompt: target.prompt,
|
||||
};
|
||||
});
|
||||
}
|
||||
result[node] = normalized;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -106,15 +104,14 @@ export async function materializeWorkflowPayload(
|
||||
name: raw.name,
|
||||
description: raw.description,
|
||||
roles,
|
||||
conditions: raw.conditions,
|
||||
graph: normalizeGraph(raw.graph),
|
||||
};
|
||||
}
|
||||
|
||||
export async function cmdWorkflowPut(
|
||||
export async function cmdWorkflowAdd(
|
||||
storageRoot: string,
|
||||
filePath: string,
|
||||
): Promise<WorkflowPutOutput> {
|
||||
): Promise<WorkflowAddOutput> {
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(filePath, "utf8");
|
||||
|
||||
@@ -7,6 +7,6 @@ export function formatOutput(data: unknown, format: OutputFormat): string {
|
||||
case "json":
|
||||
return JSON.stringify(data);
|
||||
case "yaml":
|
||||
return stringify(data).trimEnd();
|
||||
return stringify(data, { aliasDuplicateObjects: false }).trimEnd();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,23 +30,12 @@ function isRoleDefinition(value: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isConditionDefinition(value: unknown): boolean {
|
||||
function isTarget(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
return typeof value.description === "string" && typeof value.expression === "string";
|
||||
}
|
||||
|
||||
function isTransition(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const condition = value.condition;
|
||||
return (
|
||||
typeof value.role === "string" &&
|
||||
typeof value.prompt === "string" &&
|
||||
value.prompt.trim() !== "" &&
|
||||
(condition === null || condition === undefined || typeof condition === "string")
|
||||
typeof value.role === "string" && typeof value.prompt === "string" && value.prompt.trim() !== ""
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,7 +51,7 @@ function isGraph(value: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
return Object.values(value).every(
|
||||
(transitions) => Array.isArray(transitions) && transitions.every((t) => isTransition(t)),
|
||||
(statusMap) => isRecord(statusMap) && Object.values(statusMap).every((t) => isTarget(t)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,11 +90,7 @@ export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
|
||||
if (typeof raw.name !== "string" || typeof raw.description !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!isStringRecord(raw.roles, isRoleDefinition) ||
|
||||
!isStringRecord(raw.conditions, isConditionDefinition) ||
|
||||
!isGraph(raw.graph)
|
||||
) {
|
||||
if (!isStringRecord(raw.roles, isRoleDefinition) || !isGraph(raw.graph)) {
|
||||
return null;
|
||||
}
|
||||
return raw as WorkflowPayload;
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# @uncaged/workflow-agent-builtin
|
||||
|
||||
`uwf-builtin` agent — built-in LLM agent with file read/write and shell tools.
|
||||
|
||||
## Overview
|
||||
|
||||
Layer 3 agent implementation. Runs an OpenAI-compatible chat completion loop with built-in tools (`read_file`, `write_file`, `run_command`). Uses the configured provider/model from `config.yaml`. Produces frontmatter markdown output and stores turn-by-turn session detail in CAS.
|
||||
|
||||
Useful when you want a self-contained agent without an external CLI like Hermes or Claude Code.
|
||||
|
||||
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-agent-kit`, `@uncaged/workflow-util`
|
||||
|
||||
## Installation
|
||||
|
||||
Included as the `uwf-builtin` binary when you install `@uncaged/workflow-agent-builtin`:
|
||||
|
||||
```bash
|
||||
bun add -g @uncaged/workflow-agent-builtin
|
||||
```
|
||||
|
||||
## CLI Usage
|
||||
|
||||
Invoked by `uwf thread step`:
|
||||
|
||||
```bash
|
||||
uwf-builtin <thread-id> <role>
|
||||
```
|
||||
|
||||
Configure as default agent:
|
||||
|
||||
```bash
|
||||
uwf setup --agent builtin
|
||||
```
|
||||
|
||||
Override per step:
|
||||
|
||||
```bash
|
||||
uwf thread step <thread-id> --agent uwf-builtin
|
||||
```
|
||||
|
||||
Environment variables set by the engine:
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `UWF_EDGE_PROMPT` | Moderator edge instruction for this step |
|
||||
|
||||
## API
|
||||
|
||||
All exports come from `src/index.ts`.
|
||||
|
||||
### Agent factory
|
||||
|
||||
```typescript
|
||||
function createBuiltinAgent(): () => Promise<void>
|
||||
function buildBuiltinMessages(ctx: AgentContext): ChatMessage[]
|
||||
```
|
||||
|
||||
### LLM loop
|
||||
|
||||
```typescript
|
||||
const BUILTIN_MAX_TURNS = 30;
|
||||
const BUILTIN_CONTINUE_MAX_TURNS = 5;
|
||||
|
||||
function runBuiltinLoop(/* options: RunBuiltinLoopOptions */): Promise<RunBuiltinLoopResult>
|
||||
function chatCompletionWithTools(
|
||||
provider: ResolvedLlmProvider,
|
||||
messages: ChatMessage[],
|
||||
tools: OpenAiToolDefinition[],
|
||||
): Promise<LlmAssistantResponse>
|
||||
```
|
||||
|
||||
`RunBuiltinLoopOptions` and `RunBuiltinLoopResult` are internal to `loop.ts` and not re-exported from `index.ts`.
|
||||
|
||||
### Tools
|
||||
|
||||
```typescript
|
||||
function getBuiltinTools(): readonly BuiltinTool[]
|
||||
function executeBuiltinTool(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
): Promise<string>
|
||||
```
|
||||
|
||||
### Session and detail
|
||||
|
||||
```typescript
|
||||
function initSessionDir(storageRoot: string): Promise<void>
|
||||
function appendSessionTurn(storageRoot: string, sessionId: string, turn: BuiltinTurnPayload): Promise<void>
|
||||
function readSessionTurns(storageRoot: string, sessionId: string): Promise<BuiltinTurnPayload[]>
|
||||
function removeSession(storageRoot: string, sessionId: string): Promise<void>
|
||||
function registerBuiltinSchemas(store: Store): Promise<BuiltinSchemaHashes>
|
||||
function storeBuiltinDetail(store: Store, payload: BuiltinDetailPayload): Promise<string>
|
||||
```
|
||||
|
||||
### Types
|
||||
|
||||
```typescript
|
||||
type ChatMessage = /* system | user | assistant | tool */;
|
||||
type LlmAssistantResponse = { content: string | null; toolCalls: LlmToolCall[] | null };
|
||||
type LlmToolCall = { id: string; name: string; arguments: string };
|
||||
type BuiltinTool = { name: string; description: string; parameters: Record<string, unknown> };
|
||||
type ToolContext = { cwd: string; storageRoot: string };
|
||||
type BuiltinDetailPayload = { /* session turns, model, timestamps */ };
|
||||
type BuiltinLoopTurn = { /* single loop iteration record */ };
|
||||
type BuiltinToolCallRecord = { /* tool call audit */ };
|
||||
type BuiltinToolResultRecord = { /* tool result audit */ };
|
||||
type BuiltinTurnPayload = { /* persisted turn */ };
|
||||
```
|
||||
|
||||
## Internal Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts
|
||||
├── cli.ts Binary entrypoint
|
||||
├── agent.ts createBuiltinAgent
|
||||
├── loop.ts Multi-turn LLM + tool loop
|
||||
├── prompt.ts buildBuiltinMessages
|
||||
├── session.ts Session directory persistence
|
||||
├── detail.ts CAS detail node storage
|
||||
├── schemas.ts Builtin CAS schemas
|
||||
├── types.ts Detail and turn payload types
|
||||
├── llm/
|
||||
│ ├── index.ts
|
||||
│ ├── llm.ts chatCompletionWithTools
|
||||
│ └── types.ts ChatMessage, LlmToolCall, etc.
|
||||
└── tools/
|
||||
├── index.ts getBuiltinTools, executeBuiltinTool
|
||||
├── read-file.ts
|
||||
├── write-file.ts
|
||||
├── run-command.ts
|
||||
├── path.ts
|
||||
└── types.ts
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Requires a configured OpenAI-compatible provider and model in `~/.uncaged/workflow/config.yaml` (via `uwf setup`). API keys are loaded from `~/.uncaged/workflow/.env`.
|
||||
|
||||
Tools run with the current working directory as `ToolContext.cwd` (typically the directory where `uwf thread step` was invoked).
|
||||
@@ -0,0 +1,256 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
|
||||
const mockChatCompletionWithTools = mock(async () => ({
|
||||
content: "---\nstatus: done\n---",
|
||||
toolCalls: [],
|
||||
}));
|
||||
const mockAppendSessionTurn = mock(async () => {});
|
||||
const mockExecuteBuiltinTool = mock(async () => "tool-result");
|
||||
|
||||
mock.module("../src/llm/index.js", () => ({
|
||||
chatCompletionWithTools: mockChatCompletionWithTools,
|
||||
}));
|
||||
mock.module("../src/session.js", () => ({
|
||||
appendSessionTurn: mockAppendSessionTurn,
|
||||
}));
|
||||
mock.module("../src/tools/index.js", () => ({
|
||||
builtinToolsToOpenAi: () => [],
|
||||
executeBuiltinTool: mockExecuteBuiltinTool,
|
||||
getBuiltinTools: () => [],
|
||||
}));
|
||||
|
||||
import {
|
||||
executeTurnTools,
|
||||
extractFinalText,
|
||||
runBuiltinLoop,
|
||||
shouldInjectDeadlineWarning,
|
||||
shouldNudge,
|
||||
shouldProcessToolCalls,
|
||||
} from "../src/loop.js";
|
||||
|
||||
const fakeProvider = {} as any;
|
||||
const fakeToolCtx = {} as any;
|
||||
|
||||
function makeOptions(overrides: Partial<Parameters<typeof runBuiltinLoop>[0]> = {}) {
|
||||
return {
|
||||
provider: fakeProvider,
|
||||
messages: [{ role: "system" as const, content: "sys" }],
|
||||
toolCtx: fakeToolCtx,
|
||||
maxTurns: 5,
|
||||
storageRoot: "/tmp",
|
||||
sessionId: "sess",
|
||||
noTools: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockChatCompletionWithTools.mockReset();
|
||||
mockAppendSessionTurn.mockReset();
|
||||
mockExecuteBuiltinTool.mockReset();
|
||||
});
|
||||
|
||||
describe("shouldNudge", () => {
|
||||
test("2.1 returns true when all conditions met", () => {
|
||||
expect(shouldNudge({ noTools: false, text: "some text", turn: 0, maxTurns: 5 })).toBe(true);
|
||||
});
|
||||
test("2.2 returns false when noTools=true", () => {
|
||||
expect(shouldNudge({ noTools: true, text: "some text", turn: 0, maxTurns: 5 })).toBe(false);
|
||||
});
|
||||
test("2.3 returns false when text starts with ---", () => {
|
||||
expect(shouldNudge({ noTools: false, text: "---\nstatus: done", turn: 0, maxTurns: 5 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
test("2.4 returns false on last turn", () => {
|
||||
expect(shouldNudge({ noTools: false, text: "some text", turn: 4, maxTurns: 5 })).toBe(false);
|
||||
});
|
||||
test("2.5 returns true on second-to-last turn", () => {
|
||||
expect(shouldNudge({ noTools: false, text: "some text", turn: 3, maxTurns: 5 })).toBe(true);
|
||||
});
|
||||
test("2.6 leading whitespace before --- suppresses nudge", () => {
|
||||
expect(shouldNudge({ noTools: false, text: " ---\nstatus: done", turn: 0, maxTurns: 5 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeTurnTools", () => {
|
||||
test("4.1 executes each tool call and pushes tool result messages", async () => {
|
||||
mockExecuteBuiltinTool.mockResolvedValue("result");
|
||||
const messages: any[] = [];
|
||||
const calls = [
|
||||
{ id: "c1", name: "tool_a", arguments: "{}" },
|
||||
{ id: "c2", name: "tool_b", arguments: "{}" },
|
||||
];
|
||||
const count = await executeTurnTools(calls, fakeToolCtx, messages, "/tmp", "sess");
|
||||
expect(messages.length).toBe(2);
|
||||
expect(messages[0].role).toBe("tool");
|
||||
expect(messages[1].role).toBe("tool");
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
test("4.2 tool result content matches executeBuiltinTool return value", async () => {
|
||||
mockExecuteBuiltinTool.mockResolvedValue("result-A");
|
||||
const messages: any[] = [];
|
||||
await executeTurnTools(
|
||||
[{ id: "c1", name: "read_file", arguments: "{}" }],
|
||||
fakeToolCtx,
|
||||
messages,
|
||||
"/tmp",
|
||||
"sess",
|
||||
);
|
||||
expect(messages[0].content).toBe("result-A");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runBuiltinLoop integration", () => {
|
||||
test("3.1 single text-only response returns finalText immediately", async () => {
|
||||
mockChatCompletionWithTools.mockResolvedValue({
|
||||
content: "---\nstatus: done\n---",
|
||||
toolCalls: [],
|
||||
});
|
||||
const result = await runBuiltinLoop(makeOptions());
|
||||
expect(result.finalText).toBe("---\nstatus: done\n---");
|
||||
expect(result.turnCount).toBe(1);
|
||||
});
|
||||
test("3.2 noTools=true suppresses tool calls", async () => {
|
||||
mockChatCompletionWithTools.mockResolvedValue({
|
||||
content: "ok",
|
||||
toolCalls: [{ id: "c1", name: "read_file", arguments: "{}" }],
|
||||
});
|
||||
const result = await runBuiltinLoop(makeOptions({ noTools: true }));
|
||||
expect(result.finalText).toBe("ok");
|
||||
expect(result.turnCount).toBe(1);
|
||||
});
|
||||
test("3.3 tool call followed by text response", async () => {
|
||||
mockChatCompletionWithTools
|
||||
.mockResolvedValueOnce({
|
||||
content: null,
|
||||
toolCalls: [{ id: "c1", name: "read_file", arguments: "{}" }],
|
||||
})
|
||||
.mockResolvedValueOnce({ content: "---\nstatus: done\n---", toolCalls: [] });
|
||||
mockExecuteBuiltinTool.mockResolvedValue("file contents");
|
||||
const result = await runBuiltinLoop(makeOptions());
|
||||
expect(result.finalText).toBe("---\nstatus: done\n---");
|
||||
expect(result.turnCount).toBe(3);
|
||||
});
|
||||
test("3.4 nudge cycle inserts nudge message", async () => {
|
||||
mockChatCompletionWithTools
|
||||
.mockResolvedValueOnce({ content: "I am thinking", toolCalls: [] })
|
||||
.mockResolvedValueOnce({ content: "---\nstatus: done\n---", toolCalls: [] });
|
||||
const result = await runBuiltinLoop(makeOptions());
|
||||
expect(result.finalText).toBe("---\nstatus: done\n---");
|
||||
const nudgeMsg = result.messages.find(
|
||||
(m) =>
|
||||
m.role === "user" && typeof m.content === "string" && m.content.includes("frontmatter"),
|
||||
);
|
||||
expect(nudgeMsg).toBeDefined();
|
||||
});
|
||||
test("3.5 maxTurns exhaustion falls back to last assistant content", async () => {
|
||||
mockChatCompletionWithTools.mockResolvedValue({ content: "still thinking", toolCalls: [] });
|
||||
const result = await runBuiltinLoop(makeOptions({ maxTurns: 3 }));
|
||||
expect(result.finalText).toBe("still thinking");
|
||||
});
|
||||
test("3.6 original messages array is not mutated", async () => {
|
||||
mockChatCompletionWithTools.mockResolvedValue({
|
||||
content: "---\nstatus: done\n---",
|
||||
toolCalls: [],
|
||||
});
|
||||
const original = [{ role: "system" as const, content: "sys" }];
|
||||
await runBuiltinLoop(makeOptions({ messages: original }));
|
||||
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,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { resolvePath } from "../src/tools/path.js";
|
||||
import { resolve } from "node:path";
|
||||
import { resolvePath } from "../src/tools/path.js";
|
||||
|
||||
describe("resolvePath", () => {
|
||||
test("resolves relative paths against cwd", () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { AgentContext } from "@uncaged/workflow-agent-kit";
|
||||
|
||||
import { buildBuiltinPrompt } from "../src/prompt.js";
|
||||
import { buildBuiltinMessages } from "../src/prompt.js";
|
||||
|
||||
function minimalContext(overrides: Partial<AgentContext> = {}): AgentContext {
|
||||
return {
|
||||
@@ -11,11 +11,13 @@ function minimalContext(overrides: Partial<AgentContext> = {}): AgentContext {
|
||||
store: {} as AgentContext["store"],
|
||||
workflow: {
|
||||
name: "test",
|
||||
description: "test workflow",
|
||||
roles: {
|
||||
developer: {
|
||||
description: "Developer role",
|
||||
goal: "Ship the fix",
|
||||
capabilities: ["file-edit"],
|
||||
procedure: ["Edit files"],
|
||||
procedure: "Edit files",
|
||||
output: "A patch",
|
||||
frontmatter: "schema-hash",
|
||||
},
|
||||
@@ -32,24 +34,30 @@ function minimalContext(overrides: Partial<AgentContext> = {}): AgentContext {
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildBuiltinPrompt", () => {
|
||||
describe("buildBuiltinMessages", () => {
|
||||
test("system includes output format and role goal", () => {
|
||||
const { system } = buildBuiltinPrompt(minimalContext());
|
||||
expect(system).toContain("status: done");
|
||||
expect(system).toContain("## Goal");
|
||||
expect(system).toContain("Ship the fix");
|
||||
const messages = buildBuiltinMessages(minimalContext());
|
||||
const system = messages[0];
|
||||
expect(system?.role).toBe("system");
|
||||
if (system?.role === "system") {
|
||||
expect(system.content).toContain("status: done");
|
||||
expect(system.content).toContain("## Goal");
|
||||
expect(system.content).toContain("Ship the fix");
|
||||
}
|
||||
});
|
||||
|
||||
test("user includes task and edge prompt", () => {
|
||||
const { user } = buildBuiltinPrompt(minimalContext());
|
||||
expect(user).toContain("## Task");
|
||||
expect(user).toContain("Fix the bug");
|
||||
expect(user).toContain("## Current Step Instruction");
|
||||
expect(user).toContain("Implement the fix");
|
||||
test("first visit produces system + single user message with edge prompt", () => {
|
||||
const messages = buildBuiltinMessages(minimalContext());
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[1]?.role).toBe("user");
|
||||
if (messages[1]?.role === "user") {
|
||||
expect(messages[1].content).toContain("Implement the fix");
|
||||
expect(messages[1].content).not.toContain("## What Happened Since Your Last Turn");
|
||||
}
|
||||
});
|
||||
|
||||
test("user includes history when steps exist", () => {
|
||||
const { user } = buildBuiltinPrompt(
|
||||
test("first visit with prior steps includes inter-step summary in final user message", () => {
|
||||
const messages = buildBuiltinMessages(
|
||||
minimalContext({
|
||||
steps: [
|
||||
{
|
||||
@@ -57,11 +65,172 @@ describe("buildBuiltinPrompt", () => {
|
||||
output: { plan: "step 1" },
|
||||
agent: "uwf-builtin",
|
||||
detail: "detail-hash",
|
||||
edgePrompt: "Create a plan.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(user).toContain("## Previous Steps");
|
||||
expect(user).toContain("planner");
|
||||
expect(messages).toHaveLength(2);
|
||||
const finalUser = messages[1];
|
||||
if (finalUser?.role === "user") {
|
||||
expect(finalUser.content).toContain("Implement the fix");
|
||||
expect(finalUser.content).toContain("## What Happened Since Your Last Turn");
|
||||
expect(finalUser.content).toContain("planner");
|
||||
}
|
||||
});
|
||||
|
||||
test("re-entry reconstructs prior user/assistant turns plus current user message", () => {
|
||||
const messages = buildBuiltinMessages(
|
||||
minimalContext({
|
||||
isFirstVisit: false,
|
||||
edgePrompt: "Fix the reviewer's feedback.",
|
||||
steps: [
|
||||
{
|
||||
role: "developer",
|
||||
output: { summary: "Initial fix" },
|
||||
agent: "uwf-builtin",
|
||||
detail: "detail-1",
|
||||
edgePrompt: "Implement the fix.",
|
||||
},
|
||||
{
|
||||
role: "reviewer",
|
||||
output: { approved: false, comments: "Missing tests" },
|
||||
agent: "uwf-builtin",
|
||||
detail: "detail-2",
|
||||
edgePrompt: "Review the implementation.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(messages).toHaveLength(4);
|
||||
expect(messages[0]?.role).toBe("system");
|
||||
expect(messages[1]?.role).toBe("user");
|
||||
expect(messages[2]?.role).toBe("assistant");
|
||||
expect(messages[3]?.role).toBe("user");
|
||||
|
||||
if (messages[1]?.role === "user") {
|
||||
expect(messages[1].content).toBe("Implement the fix.");
|
||||
}
|
||||
if (messages[2]?.role === "assistant") {
|
||||
expect(messages[2].content).toBe(JSON.stringify({ summary: "Initial fix" }));
|
||||
}
|
||||
if (messages[3]?.role === "user") {
|
||||
expect(messages[3].content).toContain("Fix the reviewer's feedback.");
|
||||
expect(messages[3].content).toContain("## What Happened Since Your Last Turn");
|
||||
expect(messages[3].content).toContain("reviewer");
|
||||
expect(messages[3].content).toContain("Missing tests");
|
||||
}
|
||||
});
|
||||
|
||||
test("prefix is stable across re-entry for LLM cache hits", () => {
|
||||
const firstVisitMessages = buildBuiltinMessages(
|
||||
minimalContext({
|
||||
edgePrompt: "Implement the fix.",
|
||||
steps: [],
|
||||
}),
|
||||
);
|
||||
|
||||
const reEntryMessages = buildBuiltinMessages(
|
||||
minimalContext({
|
||||
isFirstVisit: false,
|
||||
edgePrompt: "Fix the reviewer's feedback.",
|
||||
steps: [
|
||||
{
|
||||
role: "developer",
|
||||
output: { summary: "Initial fix" },
|
||||
agent: "uwf-builtin",
|
||||
detail: "detail-1",
|
||||
edgePrompt: "Implement the fix.",
|
||||
},
|
||||
{
|
||||
role: "reviewer",
|
||||
output: { approved: false },
|
||||
agent: "uwf-builtin",
|
||||
detail: "detail-2",
|
||||
edgePrompt: "Review the code.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(reEntryMessages[0]).toEqual(firstVisitMessages[0]);
|
||||
expect(reEntryMessages[1]).toEqual(firstVisitMessages[1]);
|
||||
expect(reEntryMessages[2]?.role).toBe("assistant");
|
||||
if (reEntryMessages[2]?.role === "assistant") {
|
||||
expect(reEntryMessages[2].content).toBe(JSON.stringify({ summary: "Initial fix" }));
|
||||
}
|
||||
expect(reEntryMessages[3]?.role).toBe("user");
|
||||
if (reEntryMessages[3]?.role === "user") {
|
||||
expect(reEntryMessages[3].content).toContain("Fix the reviewer's feedback.");
|
||||
}
|
||||
});
|
||||
|
||||
test("multiple prior visits emit one user/assistant pair per visit", () => {
|
||||
const messages = buildBuiltinMessages(
|
||||
minimalContext({
|
||||
isFirstVisit: false,
|
||||
edgePrompt: "Third round fix.",
|
||||
steps: [
|
||||
{
|
||||
role: "developer",
|
||||
output: { round: 1 },
|
||||
agent: "uwf-builtin",
|
||||
detail: "d1",
|
||||
edgePrompt: "First attempt.",
|
||||
},
|
||||
{
|
||||
role: "reviewer",
|
||||
output: { approved: false },
|
||||
agent: "uwf-builtin",
|
||||
detail: "d2",
|
||||
edgePrompt: "Review round 1.",
|
||||
},
|
||||
{
|
||||
role: "developer",
|
||||
output: { round: 2 },
|
||||
agent: "uwf-builtin",
|
||||
detail: "d3",
|
||||
edgePrompt: "Second attempt.",
|
||||
},
|
||||
{
|
||||
role: "reviewer",
|
||||
output: { approved: false },
|
||||
agent: "uwf-builtin",
|
||||
detail: "d4",
|
||||
edgePrompt: "Review round 2.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(messages).toHaveLength(6);
|
||||
expect(messages.map((m) => m.role)).toEqual([
|
||||
"system",
|
||||
"user",
|
||||
"assistant",
|
||||
"user",
|
||||
"assistant",
|
||||
"user",
|
||||
]);
|
||||
|
||||
if (messages[1]?.role === "user") {
|
||||
expect(messages[1].content).toBe("First attempt.");
|
||||
}
|
||||
if (messages[2]?.role === "assistant") {
|
||||
expect(messages[2].content).toBe(JSON.stringify({ round: 1 }));
|
||||
}
|
||||
if (messages[3]?.role === "user") {
|
||||
expect(messages[3].content).toContain("Second attempt.");
|
||||
expect(messages[3].content).toContain("reviewer");
|
||||
}
|
||||
if (messages[4]?.role === "assistant") {
|
||||
expect(messages[4].content).toBe(JSON.stringify({ round: 2 }));
|
||||
}
|
||||
if (messages[5]?.role === "user") {
|
||||
expect(messages[5].content).toContain("Third round fix.");
|
||||
expect(messages[5].content).toContain("### Step 4: reviewer");
|
||||
expect(messages[5].content).toContain('"approved":false');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,17 +7,44 @@ import {
|
||||
resolveModel,
|
||||
resolveStorageRoot,
|
||||
} from "@uncaged/workflow-agent-kit";
|
||||
import { generateUlid } from "@uncaged/workflow-util";
|
||||
import { createLogger, generateUlid } from "@uncaged/workflow-util";
|
||||
|
||||
import { storeBuiltinDetail } from "./detail.js";
|
||||
import type { ChatMessage } from "./llm/index.js";
|
||||
import { BUILTIN_CONTINUE_MAX_TURNS, BUILTIN_MAX_TURNS, runBuiltinLoop } from "./loop.js";
|
||||
import { buildBuiltinPrompt } from "./prompt.js";
|
||||
import type { BuiltinSessionState } from "./types.js";
|
||||
import { buildBuiltinMessages } from "./prompt.js";
|
||||
import { initSessionDir } from "./session.js";
|
||||
|
||||
const sessions = new Map<string, BuiltinSessionState>();
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
function getSession(sessionId: string): BuiltinSessionState {
|
||||
const FRONTMATTER_FENCE = "---";
|
||||
|
||||
/**
|
||||
* Strip any text before the first `---` fence.
|
||||
* LLMs sometimes emit preamble text before the frontmatter block.
|
||||
*/
|
||||
function stripPreamble(text: string): string {
|
||||
if (text.startsWith(FRONTMATTER_FENCE)) {
|
||||
return text;
|
||||
}
|
||||
const idx = text.indexOf(`\n${FRONTMATTER_FENCE}\n`);
|
||||
if (idx !== -1) {
|
||||
log("6GWRP3QX", `stripped ${idx + 1} chars of preamble before frontmatter`);
|
||||
return text.slice(idx + 1);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
type SessionRecord = {
|
||||
sessionId: string;
|
||||
model: string;
|
||||
startedAtMs: number;
|
||||
messages: ChatMessage[];
|
||||
};
|
||||
|
||||
const sessions = new Map<string, SessionRecord>();
|
||||
|
||||
function getSession(sessionId: string): SessionRecord {
|
||||
const session = sessions.get(sessionId);
|
||||
if (session === undefined) {
|
||||
throw new Error(`builtin session not found: ${sessionId}`);
|
||||
@@ -36,31 +63,38 @@ async function runBuiltinWithMessages(
|
||||
storageRoot: string,
|
||||
provider: ReturnType<typeof resolveModel>,
|
||||
messages: ChatMessage[],
|
||||
session: BuiltinSessionState,
|
||||
session: SessionRecord,
|
||||
store: Store,
|
||||
maxTurns: number,
|
||||
noTools: boolean,
|
||||
): Promise<AgentRunResult> {
|
||||
const loopResult = await runBuiltinLoop({
|
||||
provider,
|
||||
messages,
|
||||
toolCtx: buildToolContext(storageRoot),
|
||||
maxTurns,
|
||||
existingTurns: session.turns,
|
||||
storageRoot,
|
||||
sessionId: session.sessionId,
|
||||
noTools,
|
||||
});
|
||||
|
||||
session.messages = loopResult.messages;
|
||||
session.turns = loopResult.turns;
|
||||
|
||||
const { detailHash, output } = await storeBuiltinDetail(
|
||||
if (loopResult.turnCount === 0) {
|
||||
log("5RWTK9NB", "no turns produced, returning empty output");
|
||||
return { output: "", detailHash: "", sessionId: session.sessionId };
|
||||
}
|
||||
|
||||
// Read jsonl → persist turns to CAS → store detail
|
||||
const { detailHash } = await storeBuiltinDetail(
|
||||
store,
|
||||
storageRoot,
|
||||
session.sessionId,
|
||||
session.model,
|
||||
session.startedAtMs,
|
||||
session.turns,
|
||||
);
|
||||
|
||||
const finalOutput = output !== "" ? output : loopResult.finalText;
|
||||
return { output: finalOutput, detailHash, sessionId: session.sessionId };
|
||||
return { output: stripPreamble(loopResult.finalText), detailHash, sessionId: session.sessionId };
|
||||
}
|
||||
|
||||
async function runBuiltin(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
@@ -69,18 +103,14 @@ async function runBuiltin(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
const provider = resolveModel(config, config.defaultModel);
|
||||
|
||||
const sessionId = generateUlid(Date.now());
|
||||
const promptParts = buildBuiltinPrompt(ctx);
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: "system", content: promptParts.system },
|
||||
{ role: "user", content: promptParts.user },
|
||||
];
|
||||
await initSessionDir(storageRoot);
|
||||
const messages = buildBuiltinMessages(ctx);
|
||||
|
||||
const session: BuiltinSessionState = {
|
||||
const session: SessionRecord = {
|
||||
sessionId,
|
||||
model: provider.model,
|
||||
startedAtMs: Date.now(),
|
||||
messages,
|
||||
turns: [],
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
@@ -91,6 +121,7 @@ async function runBuiltin(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
session,
|
||||
ctx.store,
|
||||
BUILTIN_MAX_TURNS,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,6 +144,7 @@ async function continueBuiltin(
|
||||
session,
|
||||
store,
|
||||
BUILTIN_CONTINUE_MAX_TURNS,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,72 +1,15 @@
|
||||
import { bootstrap, putSchema, type Store } from "@uncaged/json-cas";
|
||||
|
||||
import { BUILTIN_DETAIL_SCHEMA, BUILTIN_TURN_SCHEMA } from "./schemas.js";
|
||||
import type {
|
||||
BuiltinDetailPayload,
|
||||
BuiltinLoopTurn,
|
||||
BuiltinToolCall,
|
||||
BuiltinTurnPayload,
|
||||
BuiltinTurnRole,
|
||||
} from "./types.js";
|
||||
|
||||
function mapToolCalls(calls: NonNullable<BuiltinLoopTurn["toolCalls"]>): BuiltinToolCall[] {
|
||||
return calls.map((call) => ({
|
||||
name: call.name,
|
||||
args: call.args,
|
||||
}));
|
||||
}
|
||||
|
||||
function loopTurnToAssistantPayload(turn: BuiltinLoopTurn, index: number): BuiltinTurnPayload {
|
||||
return {
|
||||
index,
|
||||
role: "assistant",
|
||||
content: turn.assistantContent ?? "",
|
||||
toolCalls:
|
||||
turn.toolCalls !== null && turn.toolCalls.length > 0 ? mapToolCalls(turn.toolCalls) : null,
|
||||
reasoning: null,
|
||||
};
|
||||
}
|
||||
|
||||
function loopTurnToToolPayloads(turn: BuiltinLoopTurn, startIndex: number): BuiltinTurnPayload[] {
|
||||
if (turn.toolResults === null || turn.toolResults.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const payloads: BuiltinTurnPayload[] = [];
|
||||
let index = startIndex;
|
||||
for (const result of turn.toolResults) {
|
||||
payloads.push({
|
||||
index,
|
||||
role: "tool" as BuiltinTurnRole,
|
||||
content: result.content,
|
||||
toolCalls: null,
|
||||
reasoning: null,
|
||||
});
|
||||
index += 1;
|
||||
}
|
||||
return payloads;
|
||||
}
|
||||
|
||||
/** Last assistant message with non-empty text. */
|
||||
export function extractFinalAssistantText(turns: BuiltinLoopTurn[]): string {
|
||||
for (let i = turns.length - 1; i >= 0; i--) {
|
||||
const turn = turns[i];
|
||||
if (turn === undefined) {
|
||||
continue;
|
||||
}
|
||||
const text = turn.assistantContent;
|
||||
if (text !== null && text.trim() !== "") {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
import { readSessionTurns } from "./session.js";
|
||||
import type { BuiltinDetailPayload } from "./types.js";
|
||||
|
||||
type BuiltinSchemaHashes = {
|
||||
turn: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
async function registerBuiltinSchemas(store: Store): Promise<BuiltinSchemaHashes> {
|
||||
export async function registerBuiltinSchemas(store: Store): Promise<BuiltinSchemaHashes> {
|
||||
await bootstrap(store);
|
||||
const [turn, detail] = await Promise.all([
|
||||
putSchema(store, BUILTIN_TURN_SCHEMA),
|
||||
@@ -75,30 +18,22 @@ async function registerBuiltinSchemas(store: Store): Promise<BuiltinSchemaHashes
|
||||
return { turn, detail };
|
||||
}
|
||||
|
||||
/** Read session jsonl, persist each turn to CAS, return detail hash. */
|
||||
export async function storeBuiltinDetail(
|
||||
store: Store,
|
||||
storageRoot: string,
|
||||
sessionId: string,
|
||||
model: string,
|
||||
startedAtMs: number,
|
||||
turns: BuiltinLoopTurn[],
|
||||
nowMs: number = Date.now(),
|
||||
): Promise<{ detailHash: string; output: string }> {
|
||||
): Promise<{ detailHash: string; turnCount: number }> {
|
||||
const schemas = await registerBuiltinSchemas(store);
|
||||
const turns = await readSessionTurns(storageRoot, sessionId);
|
||||
|
||||
const turnHashes: string[] = [];
|
||||
let turnIndex = 0;
|
||||
|
||||
for (const loopTurn of turns) {
|
||||
const assistant = loopTurnToAssistantPayload(loopTurn, turnIndex);
|
||||
const assistantHash = await store.put(schemas.turn, assistant);
|
||||
turnHashes.push(assistantHash);
|
||||
turnIndex += 1;
|
||||
|
||||
const toolPayloads = loopTurnToToolPayloads(loopTurn, turnIndex);
|
||||
for (const toolPayload of toolPayloads) {
|
||||
const toolHash = await store.put(schemas.turn, toolPayload);
|
||||
turnHashes.push(toolHash);
|
||||
turnIndex += 1;
|
||||
}
|
||||
for (const turn of turns) {
|
||||
const hash = await store.put(schemas.turn, turn);
|
||||
turnHashes.push(hash);
|
||||
}
|
||||
|
||||
const duration = Math.max(0, nowMs - startedAtMs);
|
||||
@@ -110,6 +45,5 @@ export async function storeBuiltinDetail(
|
||||
turns: turnHashes,
|
||||
};
|
||||
const detailHash = await store.put(schemas.detail, detail);
|
||||
const output = extractFinalAssistantText(turns);
|
||||
return { detailHash, output };
|
||||
return { detailHash, turnCount: turnHashes.length };
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
export { createBuiltinAgent } from "./agent.js";
|
||||
export { extractFinalAssistantText, storeBuiltinDetail } from "./detail.js";
|
||||
export { registerBuiltinSchemas, storeBuiltinDetail } from "./detail.js";
|
||||
export type { ChatMessage, LlmAssistantResponse, LlmToolCall } from "./llm/index.js";
|
||||
export { chatCompletionWithTools } from "./llm/index.js";
|
||||
export { BUILTIN_CONTINUE_MAX_TURNS, BUILTIN_MAX_TURNS, runBuiltinLoop } from "./loop.js";
|
||||
export { buildBuiltinPrompt } from "./prompt.js";
|
||||
export { buildBuiltinMessages } from "./prompt.js";
|
||||
export { appendSessionTurn, initSessionDir, readSessionTurns, removeSession } from "./session.js";
|
||||
export type { BuiltinTool, ToolContext } from "./tools/index.js";
|
||||
export { executeBuiltinTool, getBuiltinTools } from "./tools/index.js";
|
||||
export type {
|
||||
BuiltinDetailPayload,
|
||||
BuiltinLoopTurn,
|
||||
BuiltinSessionState,
|
||||
BuiltinToolCallRecord,
|
||||
BuiltinToolResultRecord,
|
||||
BuiltinTurnPayload,
|
||||
} from "./types.js";
|
||||
|
||||
@@ -96,8 +96,17 @@ function serializeMessage(message: ChatMessage): Record<string, unknown> {
|
||||
export async function chatCompletionWithTools(
|
||||
provider: ResolvedLlmProvider,
|
||||
messages: ChatMessage[],
|
||||
tools: OpenAiToolDefinition[],
|
||||
tools: OpenAiToolDefinition[] | null,
|
||||
): Promise<LlmAssistantResponse> {
|
||||
const body: Record<string, unknown> = {
|
||||
model: provider.model,
|
||||
messages: messages.map(serializeMessage),
|
||||
};
|
||||
if (tools !== null && tools.length > 0) {
|
||||
body.tools = tools;
|
||||
body.tool_choice = "auto";
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(chatUrl(provider.baseUrl), {
|
||||
@@ -106,12 +115,7 @@ export async function chatCompletionWithTools(
|
||||
Authorization: `Bearer ${provider.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: provider.model,
|
||||
messages: messages.map(serializeMessage),
|
||||
tools,
|
||||
tool_choice: "auto",
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { ResolvedLlmProvider } from "@uncaged/workflow-agent-kit";
|
||||
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 {
|
||||
builtinToolsToOpenAi,
|
||||
executeBuiltinTool,
|
||||
getBuiltinTools,
|
||||
type ToolContext,
|
||||
} from "./tools/index.js";
|
||||
import type { BuiltinLoopTurn, BuiltinToolCallRecord, BuiltinToolResultRecord } from "./types.js";
|
||||
import type { BuiltinToolCall, BuiltinTurnPayload } from "./types.js";
|
||||
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
@@ -20,91 +26,278 @@ export type RunBuiltinLoopOptions = {
|
||||
messages: ChatMessage[];
|
||||
toolCtx: ToolContext;
|
||||
maxTurns: number;
|
||||
existingTurns: BuiltinLoopTurn[];
|
||||
storageRoot: string;
|
||||
sessionId: string;
|
||||
/** When true, do not provide tools — force LLM to emit text only. */
|
||||
noTools: boolean;
|
||||
};
|
||||
|
||||
export type RunBuiltinLoopResult = {
|
||||
finalText: string;
|
||||
messages: ChatMessage[];
|
||||
turns: BuiltinLoopTurn[];
|
||||
turnCount: number;
|
||||
};
|
||||
|
||||
function mapToolCalls(calls: LlmToolCall[]): BuiltinToolCallRecord[] {
|
||||
function mapToolCallsForPayload(calls: LlmToolCall[]): BuiltinToolCall[] {
|
||||
return calls.map((call) => ({
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
args: call.arguments,
|
||||
}));
|
||||
}
|
||||
|
||||
async function appendTurn(
|
||||
storageRoot: string,
|
||||
sessionId: string,
|
||||
payload: BuiltinTurnPayload,
|
||||
): Promise<void> {
|
||||
await appendSessionTurn(storageRoot, sessionId, payload);
|
||||
}
|
||||
|
||||
export async function executeTurnTools(
|
||||
calls: Array<{ id: string; name: string; arguments: string }>,
|
||||
toolCtx: ToolContext,
|
||||
messages: ChatMessage[],
|
||||
storageRoot: string,
|
||||
sessionId: string,
|
||||
): Promise<number> {
|
||||
let turnCount = 0;
|
||||
for (const call of calls) {
|
||||
const result = await executeBuiltinTool(call.name, call.arguments, toolCtx);
|
||||
messages.push({ role: "tool", tool_call_id: call.id, content: result });
|
||||
await appendTurn(storageRoot, sessionId, {
|
||||
role: "tool",
|
||||
content: result,
|
||||
toolCalls: null,
|
||||
reasoning: null,
|
||||
});
|
||||
turnCount += 1;
|
||||
}
|
||||
return turnCount;
|
||||
}
|
||||
|
||||
export type ShouldNudgeOptions = {
|
||||
noTools: boolean;
|
||||
text: string;
|
||||
turn: number;
|
||||
maxTurns: number;
|
||||
};
|
||||
|
||||
const MAX_NUDGES = 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 {
|
||||
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. */
|
||||
export async function runBuiltinLoop(
|
||||
options: RunBuiltinLoopOptions,
|
||||
): Promise<RunBuiltinLoopResult> {
|
||||
const messages = [...options.messages];
|
||||
const turns = [...options.existingTurns];
|
||||
const openAiTools = builtinToolsToOpenAi(getBuiltinTools());
|
||||
const openAiTools = options.noTools ? [] : builtinToolsToOpenAi(getBuiltinTools());
|
||||
let finalText = "";
|
||||
let turnCount = 0;
|
||||
let nudgeCount = 0;
|
||||
let deadlineWarned = false;
|
||||
|
||||
for (let turn = 0; turn < options.maxTurns; turn++) {
|
||||
log("8K2M4N7P", `builtin loop turn ${turn + 1}/${options.maxTurns}`);
|
||||
const response = await chatCompletionWithTools(options.provider, messages, openAiTools);
|
||||
|
||||
const assistantMessage: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: response.content,
|
||||
tool_calls: response.toolCalls,
|
||||
};
|
||||
messages.push(assistantMessage);
|
||||
// Warn agent when approaching turn limit
|
||||
if (shouldInjectDeadlineWarning(turn, options.maxTurns, deadlineWarned, options.noTools)) {
|
||||
deadlineWarned = true;
|
||||
const turnsRemaining = options.maxTurns - turn;
|
||||
injectDeadlineWarning(messages, turnsRemaining);
|
||||
}
|
||||
|
||||
if (response.toolCalls === null || response.toolCalls.length === 0) {
|
||||
finalText = response.content ?? "";
|
||||
turns.push({
|
||||
assistantContent: response.content,
|
||||
toolCalls: null,
|
||||
toolResults: null,
|
||||
});
|
||||
const result = await processLoopIteration(options, messages, openAiTools, turn, nudgeCount);
|
||||
turnCount += result.turnCount;
|
||||
nudgeCount = result.nudgeCount;
|
||||
turn += result.turnAdjustment;
|
||||
|
||||
if (result.shouldBreak) {
|
||||
finalText = result.finalText;
|
||||
break;
|
||||
}
|
||||
|
||||
const toolCallRecords = mapToolCalls(response.toolCalls);
|
||||
const toolResults: BuiltinToolResultRecord[] = [];
|
||||
|
||||
for (const call of response.toolCalls) {
|
||||
const result = await executeBuiltinTool(call.name, call.arguments, options.toolCtx);
|
||||
toolResults.push({
|
||||
toolCallId: call.id,
|
||||
name: call.name,
|
||||
content: result,
|
||||
});
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: call.id,
|
||||
content: result,
|
||||
});
|
||||
}
|
||||
|
||||
turns.push({
|
||||
assistantContent: response.content,
|
||||
toolCalls: toolCallRecords,
|
||||
toolResults,
|
||||
});
|
||||
}
|
||||
|
||||
if (finalText === "" && messages.length > 0) {
|
||||
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() !== ""
|
||||
) {
|
||||
finalText = msg.content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (finalText === "") {
|
||||
finalText = extractFinalText(messages);
|
||||
}
|
||||
|
||||
return { finalText, messages, turns };
|
||||
return { finalText, messages, turnCount };
|
||||
}
|
||||
|
||||
@@ -1,31 +1,56 @@
|
||||
import { type AgentContext, buildRolePrompt } from "@uncaged/workflow-agent-kit";
|
||||
|
||||
function buildHistorySummary(steps: AgentContext["steps"]): string {
|
||||
if (steps.length === 0) {
|
||||
import type { ChatMessage } from "./llm/index.js";
|
||||
|
||||
type StepContext = AgentContext["steps"][number];
|
||||
|
||||
function formatStep(step: StepContext, stepNumber: number): string {
|
||||
return [
|
||||
`### Step ${stepNumber}: ${step.role}`,
|
||||
`Output: ${JSON.stringify(step.output)}`,
|
||||
`Agent: ${step.agent}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildStepsSummary(steps: StepContext[], fromIndex: number, toIndex: number): string {
|
||||
if (fromIndex >= toIndex) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const lines: string[] = ["## Previous Steps"];
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const lines: string[] = ["## What Happened Since Your Last Turn"];
|
||||
for (let i = fromIndex; i < toIndex; 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}`);
|
||||
lines.push(formatStep(step, i + 1));
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export type BuiltinPromptParts = {
|
||||
system: string;
|
||||
user: string;
|
||||
};
|
||||
function buildUserTurnContent(edgePrompt: string, summary: string): string {
|
||||
const parts: string[] = [];
|
||||
if (edgePrompt !== "") {
|
||||
parts.push(edgePrompt);
|
||||
}
|
||||
if (summary !== "") {
|
||||
if (parts.length > 0) {
|
||||
parts.push("");
|
||||
}
|
||||
parts.push(summary);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/** Assemble system prompt (role + format) and user prompt (task + edge + history). */
|
||||
export function buildBuiltinPrompt(ctx: AgentContext): BuiltinPromptParts {
|
||||
/**
|
||||
* Reconstruct multi-turn chat messages from thread history for cache-friendly session resume.
|
||||
*
|
||||
* - system: role prompt + output format (stable prefix)
|
||||
* - For each prior visit of this role: user (edgePrompt + inter-step summary) + assistant (output JSON)
|
||||
* - Final user: current edgePrompt + summary since last visit of this role
|
||||
*/
|
||||
export function buildBuiltinMessages(ctx: AgentContext): ChatMessage[] {
|
||||
const roleDef = ctx.workflow.roles[ctx.role];
|
||||
const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
|
||||
const systemParts: string[] = [];
|
||||
@@ -34,17 +59,57 @@ export function buildBuiltinPrompt(ctx: AgentContext): BuiltinPromptParts {
|
||||
}
|
||||
systemParts.push(rolePrompt);
|
||||
|
||||
const userParts: string[] = ["## Task", ctx.start.prompt];
|
||||
if (ctx.edgePrompt !== "") {
|
||||
userParts.push("", "## Current Step Instruction", ctx.edgePrompt);
|
||||
}
|
||||
const historyBlock = buildHistorySummary(ctx.steps);
|
||||
if (historyBlock !== "") {
|
||||
userParts.push("", historyBlock);
|
||||
systemParts.push(
|
||||
"",
|
||||
"## Workflow",
|
||||
"",
|
||||
`Your working directory is: ${process.cwd()}`,
|
||||
"",
|
||||
"You have tools available (read_file, write_file, run_command). " +
|
||||
"Use them to complete your task — read files, run commands, make changes as needed. " +
|
||||
"Your task is described in the user message below — do NOT use uwf or workflow CLI commands to discover your task. " +
|
||||
"When you are done, output your final response with the YAML frontmatter block as specified above. " +
|
||||
"Do NOT output the frontmatter until you have completed all necessary work. " +
|
||||
"If you are running low on turns and cannot finish, output the frontmatter with `status: failed` and explain what remains in the body. " +
|
||||
"CRITICAL: Your final output MUST start with the `---` fence on the very first line — " +
|
||||
"no preamble text, no explanation before it. The parser requires `---` at position 0.",
|
||||
);
|
||||
|
||||
const messages: ChatMessage[] = [{ role: "system", content: systemParts.join("\n") }];
|
||||
|
||||
const roleVisitIndices: number[] = [];
|
||||
for (let i = 0; i < ctx.steps.length; i++) {
|
||||
const step = ctx.steps[i];
|
||||
if (step !== undefined && step.role === ctx.role) {
|
||||
roleVisitIndices.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
system: systemParts.join("\n"),
|
||||
user: userParts.join("\n"),
|
||||
};
|
||||
let prevVisitIndex = -1;
|
||||
for (const visitIndex of roleVisitIndices) {
|
||||
const visitStep = ctx.steps[visitIndex];
|
||||
if (visitStep === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const summary = buildStepsSummary(ctx.steps, prevVisitIndex + 1, visitIndex);
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: buildUserTurnContent(visitStep.edgePrompt, summary),
|
||||
});
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: JSON.stringify(visitStep.output),
|
||||
tool_calls: null,
|
||||
});
|
||||
prevVisitIndex = visitIndex;
|
||||
}
|
||||
|
||||
const finalSummary = buildStepsSummary(ctx.steps, prevVisitIndex + 1, ctx.steps.length);
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: buildUserTurnContent(ctx.edgePrompt, finalSummary),
|
||||
});
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
@@ -13,9 +13,8 @@ const BUILTIN_TOOL_CALL_SCHEMA: JSONSchema = {
|
||||
export const BUILTIN_TURN_SCHEMA: JSONSchema = {
|
||||
title: "builtin-turn",
|
||||
type: "object",
|
||||
required: ["index", "role", "content"],
|
||||
required: ["role", "content"],
|
||||
properties: {
|
||||
index: { type: "integer" },
|
||||
role: { type: "string", enum: ["assistant", "tool"] },
|
||||
content: { type: "string" },
|
||||
toolCalls: {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { appendFile, mkdir, readFile, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
import type { BuiltinTurnPayload } from "./types.js";
|
||||
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
function sessionsDir(storageRoot: string): string {
|
||||
return join(storageRoot, "sessions");
|
||||
}
|
||||
|
||||
function sessionFile(storageRoot: string, sessionId: string): string {
|
||||
return join(sessionsDir(storageRoot), `${sessionId}.jsonl`);
|
||||
}
|
||||
|
||||
/** Ensure sessions directory exists. */
|
||||
export async function initSessionDir(storageRoot: string): Promise<void> {
|
||||
await mkdir(sessionsDir(storageRoot), { recursive: true });
|
||||
}
|
||||
|
||||
/** Append a turn to the session jsonl file. */
|
||||
export async function appendSessionTurn(
|
||||
storageRoot: string,
|
||||
sessionId: string,
|
||||
turn: BuiltinTurnPayload,
|
||||
): Promise<void> {
|
||||
const line = `${JSON.stringify(turn)}\n`;
|
||||
await appendFile(sessionFile(storageRoot, sessionId), line, "utf-8");
|
||||
log("3XQVN8KR", `session ${sessionId} appended ${turn.role} turn`);
|
||||
}
|
||||
|
||||
/** Read all turns from session jsonl. Returns empty array if file does not exist. */
|
||||
export async function readSessionTurns(
|
||||
storageRoot: string,
|
||||
sessionId: string,
|
||||
): Promise<BuiltinTurnPayload[]> {
|
||||
try {
|
||||
const content = await readFile(sessionFile(storageRoot, sessionId), "utf-8");
|
||||
const lines = content
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l.length > 0);
|
||||
return lines.map((l) => JSON.parse(l) as BuiltinTurnPayload);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove session jsonl file (called after detail is persisted to step CAS). */
|
||||
export async function removeSession(storageRoot: string, sessionId: string): Promise<void> {
|
||||
try {
|
||||
await rm(sessionFile(storageRoot, sessionId));
|
||||
log("7FWDP2MJ", `session ${sessionId} removed`);
|
||||
} catch {
|
||||
// already gone — fine
|
||||
}
|
||||
}
|
||||
@@ -56,8 +56,7 @@ function runShell(
|
||||
|
||||
export const runCommandTool: BuiltinTool = {
|
||||
name: "run_command",
|
||||
description:
|
||||
"Run a shell command. Output is truncated to 32KB.",
|
||||
description: "Run a shell command. Output is truncated to 32KB.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["command"],
|
||||
|
||||
@@ -34,7 +34,6 @@ export type BuiltinToolCall = {
|
||||
};
|
||||
|
||||
export type BuiltinTurnPayload = {
|
||||
index: number;
|
||||
role: BuiltinTurnRole;
|
||||
content: string;
|
||||
toolCalls: BuiltinToolCall[] | null;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# @uncaged/workflow-agent-claude-code
|
||||
|
||||
`uwf-claude-code` agent — spawns the Claude Code CLI and captures session detail.
|
||||
|
||||
## Overview
|
||||
|
||||
Layer 3 agent implementation. Spawns the `claude` CLI with a composed system prompt (role definition, task, prior steps, edge prompt). Parses stream or JSON stdout, caches session IDs for multi-turn continuation, and stores raw output plus structured detail in CAS.
|
||||
|
||||
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-agent-kit`
|
||||
|
||||
## Installation
|
||||
|
||||
Included as the `uwf-claude-code` binary when you install `@uncaged/workflow-agent-claude-code`:
|
||||
|
||||
```bash
|
||||
bun add -g @uncaged/workflow-agent-claude-code
|
||||
```
|
||||
|
||||
Requires the `claude` CLI on `PATH`.
|
||||
|
||||
## CLI Usage
|
||||
|
||||
Invoked by `uwf thread step`:
|
||||
|
||||
```bash
|
||||
uwf-claude-code <thread-id> <role>
|
||||
```
|
||||
|
||||
Configure or override the agent:
|
||||
|
||||
```bash
|
||||
uwf setup --agent claude-code
|
||||
uwf thread step <thread-id> --agent uwf-claude-code
|
||||
```
|
||||
|
||||
Environment variables set by the engine:
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `UWF_EDGE_PROMPT` | Moderator edge instruction for this step |
|
||||
|
||||
## API
|
||||
|
||||
All exports come from `src/index.ts`.
|
||||
|
||||
### Agent factory
|
||||
|
||||
```typescript
|
||||
function createClaudeCodeAgent(): () => Promise<void>
|
||||
function buildClaudeCodePrompt(ctx: AgentContext): string
|
||||
```
|
||||
|
||||
### Session detail
|
||||
|
||||
```typescript
|
||||
function parseClaudeCodeStreamOutput(stdout: string): ClaudeCodeParsedResult | null
|
||||
function parseClaudeCodeJsonOutput(stdout: string): ClaudeCodeParsedResult | null
|
||||
function storeClaudeCodeDetail(
|
||||
store: Store,
|
||||
parsed: ClaudeCodeParsedResult,
|
||||
sessionId: string,
|
||||
): Promise<string>
|
||||
function storeClaudeCodeRawOutput(store: Store, rawOutput: string): Promise<string>
|
||||
```
|
||||
|
||||
## Usage (library)
|
||||
|
||||
```typescript
|
||||
import { createClaudeCodeAgent, buildClaudeCodePrompt } from "@uncaged/workflow-agent-claude-code";
|
||||
|
||||
const main = createClaudeCodeAgent();
|
||||
void main();
|
||||
```
|
||||
|
||||
## Internal Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts
|
||||
├── cli.ts Binary entrypoint
|
||||
├── claude-code.ts createClaudeCodeAgent, buildClaudeCodePrompt, spawn logic
|
||||
├── session-detail.ts Parse stdout, store CAS detail nodes
|
||||
├── schemas.ts Claude Code detail CAS schemas
|
||||
└── types.ts ClaudeCodeParsedResult, message shapes
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Uses session caching from `@uncaged/workflow-agent-kit` (`getCachedSessionId` / `setCachedSessionId`). No separate config file — relies on the Claude Code CLI's own authentication.
|
||||
|
||||
Maximum turns per invocation: 90 (constant in `claude-code.ts`).
|
||||
@@ -39,19 +39,59 @@ describe("buildClaudeCodePrompt", () => {
|
||||
expect(result).toContain("## Task\nFix the bug");
|
||||
});
|
||||
|
||||
test("includes previous steps as history summary", () => {
|
||||
test("includes previous steps with content on first visit", () => {
|
||||
const ctx = makeCtx({
|
||||
steps: [{ role: "planner", output: '{"plan":"do X"}', agent: "hermes" }],
|
||||
steps: [
|
||||
{
|
||||
role: "planner",
|
||||
output: '{"plan":"do X"}',
|
||||
agent: "hermes",
|
||||
detail: "detail-1",
|
||||
edgePrompt: "Create a plan.",
|
||||
content: "Here is my detailed plan for doing X.",
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = buildClaudeCodePrompt(ctx);
|
||||
expect(result).toContain("## Previous Steps");
|
||||
expect(result).toContain("## What Happened Since Your Last Turn");
|
||||
expect(result).toContain("Step 1: planner");
|
||||
expect(result).toContain("do X");
|
||||
// First visit should include step content
|
||||
expect(result).toContain("Here is my detailed plan for doing X.");
|
||||
});
|
||||
|
||||
test("re-entry shows steps since last visit without content", () => {
|
||||
const ctx = makeCtx({
|
||||
isFirstVisit: false,
|
||||
steps: [
|
||||
{
|
||||
role: "developer",
|
||||
output: '{"status":"done"}',
|
||||
agent: "claude-code",
|
||||
detail: "detail-1",
|
||||
edgePrompt: "Implement.",
|
||||
content: "I implemented everything.",
|
||||
},
|
||||
{
|
||||
role: "reviewer",
|
||||
output: '{"approved":false}',
|
||||
agent: "claude-code",
|
||||
detail: "detail-2",
|
||||
edgePrompt: "Review.",
|
||||
content: "Rejected: complexity too high, refactor cmdStepRead.",
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = buildClaudeCodePrompt(ctx);
|
||||
expect(result).toContain("## What Happened Since Your Last Turn");
|
||||
expect(result).toContain("reviewer");
|
||||
expect(result).toContain("approved");
|
||||
});
|
||||
|
||||
test("omits history section when steps array is empty", () => {
|
||||
const result = buildClaudeCodePrompt(makeCtx({ steps: [] }));
|
||||
expect(result).not.toContain("## Previous Steps");
|
||||
expect(result).not.toContain("## What Happened Since Your Last Turn");
|
||||
expect(result).toContain("## Current Instruction");
|
||||
});
|
||||
|
||||
test("works without outputFormatInstruction", () => {
|
||||
|
||||
@@ -73,9 +73,7 @@ describe("parseClaudeCodeStreamOutput", () => {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "tool_result", tool_use_id: "tool_1", content: "file1.ts\nfile2.ts" },
|
||||
],
|
||||
content: [{ type: "tool_result", tool_use_id: "tool_1", content: "file1.ts\nfile2.ts" }],
|
||||
},
|
||||
session_id: "sess-123",
|
||||
}),
|
||||
@@ -156,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", () => {
|
||||
const baseParsed: ClaudeCodeParsedResult = {
|
||||
type: "result",
|
||||
@@ -167,7 +258,12 @@ describe("storeClaudeCodeDetail", () => {
|
||||
durationMs: 15000,
|
||||
model: "claude-sonnet-4.5",
|
||||
stopReason: "end_turn",
|
||||
usage: { inputTokens: 100, outputTokens: 50, cacheReadInputTokens: 0, cacheCreationInputTokens: 0 },
|
||||
usage: {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
},
|
||||
turns: [
|
||||
{ index: 0, role: "assistant", content: "hello", toolCalls: null },
|
||||
{ index: 1, role: "tool_result", content: "world", toolCalls: null },
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "^0.4.0",
|
||||
"@uncaged/workflow-agent-kit": "workspace:^"
|
||||
"@uncaged/workflow-agent-kit": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import type { Store } from "@uncaged/json-cas";
|
||||
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
import {
|
||||
type AgentContext,
|
||||
type AgentRunResult,
|
||||
buildContinuationPrompt,
|
||||
buildRolePrompt,
|
||||
createAgent,
|
||||
getCachedSessionId,
|
||||
setCachedSessionId,
|
||||
} from "@uncaged/workflow-agent-kit";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
import { parseClaudeCodeStreamOutput, storeClaudeCodeDetail } from "./session-detail.js";
|
||||
|
||||
@@ -18,25 +17,7 @@ const log = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
const CLAUDE_COMMAND = "claude";
|
||||
const CLAUDE_MAX_TURNS = 90;
|
||||
|
||||
function buildHistorySummary(steps: AgentContext["steps"]): string {
|
||||
if (steps.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const lines: string[] = ["## Previous Steps"];
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
if (step === undefined) {
|
||||
continue;
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(`### Step ${i + 1}: ${step.role}`);
|
||||
lines.push(`Output: ${JSON.stringify(step.output)}`);
|
||||
lines.push(`Agent: ${step.agent}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
const CLAUDE_MODEL = process.env.CLAUDE_MODEL ?? null;
|
||||
|
||||
/** Assemble system prompt, task, and prior step outputs for Claude Code. */
|
||||
export function buildClaudeCodePrompt(ctx: AgentContext): string {
|
||||
@@ -47,10 +28,23 @@ export function buildClaudeCodePrompt(ctx: AgentContext): string {
|
||||
parts.push(ctx.outputFormatInstruction, "");
|
||||
}
|
||||
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
|
||||
const historyBlock = buildHistorySummary(ctx.steps);
|
||||
if (historyBlock !== "") {
|
||||
parts.push("", historyBlock);
|
||||
|
||||
if (!ctx.isFirstVisit) {
|
||||
// Re-entry (session will be resumed): show only steps since last visit, meta only
|
||||
parts.push("", buildContinuationPrompt(ctx.steps, ctx.role, ctx.edgePrompt));
|
||||
} else if (ctx.steps.length > 0) {
|
||||
// First visit: show all steps with content for recent ones
|
||||
parts.push(
|
||||
"",
|
||||
buildContinuationPrompt(ctx.steps, ctx.role, ctx.edgePrompt, {
|
||||
includeContent: true,
|
||||
quota: 32000,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
parts.push("", "## Current Instruction", "", ctx.edgePrompt);
|
||||
}
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
@@ -88,7 +82,7 @@ function spawnClaude(args: string[]): Promise<{ stdout: string; stderr: string }
|
||||
}
|
||||
|
||||
function spawnClaudeRun(prompt: string): Promise<{ stdout: string; stderr: string }> {
|
||||
return spawnClaude([
|
||||
const args = [
|
||||
"-p",
|
||||
prompt,
|
||||
"--output-format",
|
||||
@@ -97,14 +91,18 @@ function spawnClaudeRun(prompt: string): Promise<{ stdout: string; stderr: strin
|
||||
"--dangerously-skip-permissions",
|
||||
"--max-turns",
|
||||
String(CLAUDE_MAX_TURNS),
|
||||
]);
|
||||
];
|
||||
if (CLAUDE_MODEL !== null) {
|
||||
args.push("--model", CLAUDE_MODEL);
|
||||
}
|
||||
return spawnClaude(args);
|
||||
}
|
||||
|
||||
function spawnClaudeResume(
|
||||
sessionId: string,
|
||||
message: string,
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return spawnClaude([
|
||||
const args = [
|
||||
"-p",
|
||||
message,
|
||||
"--resume",
|
||||
@@ -115,7 +113,11 @@ function spawnClaudeResume(
|
||||
"--dangerously-skip-permissions",
|
||||
"--max-turns",
|
||||
String(CLAUDE_MAX_TURNS),
|
||||
]);
|
||||
];
|
||||
if (CLAUDE_MODEL !== null) {
|
||||
args.push("--model", CLAUDE_MODEL);
|
||||
}
|
||||
return spawnClaude(args);
|
||||
}
|
||||
|
||||
async function processClaudeOutput(stdout: string, store: Store): Promise<AgentRunResult> {
|
||||
@@ -134,19 +136,26 @@ async function processClaudeOutput(stdout: string, store: Store): Promise<AgentR
|
||||
async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
const fullPrompt = buildClaudeCodePrompt(ctx);
|
||||
|
||||
log("K7R2M4N8", `prompt for role=${ctx.role} (length=${fullPrompt.length}):\n${fullPrompt}`);
|
||||
|
||||
// Try resuming a cached session for re-entry scenarios (e.g. reviewer reject → developer re-entry).
|
||||
if (!ctx.isFirstVisit) {
|
||||
const cachedSessionId = await getCachedSessionId(ctx.threadId, ctx.role);
|
||||
const cachedSessionId = await getCachedSessionId("claude-code", ctx.threadId, ctx.role);
|
||||
if (cachedSessionId !== null) {
|
||||
try {
|
||||
const { stdout } = await spawnClaudeResume(cachedSessionId, fullPrompt);
|
||||
const result = await processClaudeOutput(stdout, ctx.store);
|
||||
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;
|
||||
} catch (err) {
|
||||
log("5VKR8N3Q", "resume failed for session %s, falling back to fresh run: %s", cachedSessionId, err);
|
||||
log(
|
||||
"5VKR8N3Q",
|
||||
"resume failed for session %s, falling back to fresh run: %s",
|
||||
cachedSessionId,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,7 +163,7 @@ async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
const { stdout } = await spawnClaudeRun(fullPrompt);
|
||||
const result = await processClaudeOutput(stdout, ctx.store);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export const CLAUDE_CODE_DETAIL_SCHEMA: JSONSchema = {
|
||||
},
|
||||
turns: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
items: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
|
||||
@@ -67,101 +67,105 @@ function extractToolResultContent(content: unknown[]): string {
|
||||
return results.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 turns: ClaudeCodeTurnPayload[] = [];
|
||||
let resultLine: Record<string, unknown> | null = null;
|
||||
let model = "";
|
||||
let turnIndex = 0;
|
||||
type ParseState = {
|
||||
turns: ClaudeCodeTurnPayload[];
|
||||
resultLine: Record<string, unknown> | null;
|
||||
model: string;
|
||||
turnIndex: number;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
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;
|
||||
}
|
||||
function processSystemLine(parsed: Record<string, unknown>, state: ParseState): void {
|
||||
if (typeof parsed.model === "string") {
|
||||
state.model = parsed.model;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
const result = resultLine.result;
|
||||
const subtype = resultLine.subtype;
|
||||
function processUserLine(parsed: Record<string, unknown>, state: ParseState): void {
|
||||
if (!isRecord(parsed.message)) return;
|
||||
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") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const usage = isRecord(resultLine.usage) ? resultLine.usage : {};
|
||||
|
||||
const usage = isRecord(state.resultLine.usage) ? state.resultLine.usage : {};
|
||||
return {
|
||||
type: safeString(resultLine.type, "result"),
|
||||
type: safeString(state.resultLine.type, "result"),
|
||||
subtype: subtype as ClaudeCodeParsedResult["subtype"],
|
||||
result,
|
||||
sessionId,
|
||||
numTurns: safeNumber(resultLine.num_turns),
|
||||
totalCostUsd: safeNumber(resultLine.total_cost_usd),
|
||||
durationMs: safeNumber(resultLine.duration_ms),
|
||||
model,
|
||||
stopReason: safeString(resultLine.stop_reason),
|
||||
numTurns: safeNumber(state.resultLine.num_turns),
|
||||
totalCostUsd: safeNumber(state.resultLine.total_cost_usd),
|
||||
durationMs: safeNumber(state.resultLine.duration_ms),
|
||||
model: state.model,
|
||||
stopReason: safeString(state.resultLine.stop_reason),
|
||||
usage: {
|
||||
inputTokens: safeNumber(usage.input_tokens),
|
||||
outputTokens: safeNumber(usage.output_tokens),
|
||||
cacheReadInputTokens: safeNumber(usage.cache_read_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).
|
||||
* Falls back when stream-json is not available.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# @uncaged/workflow-agent-hermes
|
||||
|
||||
`uwf-hermes` agent — spawns Hermes chat via ACP and captures session detail.
|
||||
|
||||
## Overview
|
||||
|
||||
Layer 3 agent implementation. Wraps the Hermes CLI using the Agent Client Protocol (ACP). On first visit to a role it sends a composed prompt (role definition, task, history, edge prompt); on continuation it resumes the cached session. Session transcripts and raw output are stored as CAS detail nodes.
|
||||
|
||||
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-agent-kit`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`
|
||||
|
||||
## Installation
|
||||
|
||||
Included as the `uwf-hermes` binary when you install `@uncaged/workflow-agent-hermes`:
|
||||
|
||||
```bash
|
||||
bun add -g @uncaged/workflow-agent-hermes
|
||||
```
|
||||
|
||||
Requires the `hermes` CLI on `PATH`.
|
||||
|
||||
## CLI Usage
|
||||
|
||||
Invoked by `uwf thread step` (not typically run directly):
|
||||
|
||||
```bash
|
||||
uwf-hermes <thread-id> <role>
|
||||
```
|
||||
|
||||
Environment variables set by the engine:
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `UWF_EDGE_PROMPT` | Moderator edge instruction for this step |
|
||||
|
||||
Configure as the default agent via `uwf setup --agent hermes`.
|
||||
|
||||
Override per step:
|
||||
|
||||
```bash
|
||||
uwf thread step <thread-id> --agent uwf-hermes
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
All exports come from `src/index.ts`.
|
||||
|
||||
### Agent factory
|
||||
|
||||
```typescript
|
||||
function createHermesAgent(): () => Promise<void>
|
||||
function buildHermesPrompt(ctx: AgentContext): string
|
||||
```
|
||||
|
||||
### ACP client
|
||||
|
||||
```typescript
|
||||
class HermesAcpClient {
|
||||
// Spawns hermes, handles JSON-RPC over stdio
|
||||
}
|
||||
```
|
||||
|
||||
## Usage (library)
|
||||
|
||||
```typescript
|
||||
import { createHermesAgent, buildHermesPrompt } from "@uncaged/workflow-agent-hermes";
|
||||
|
||||
// CLI entry (src/cli.ts):
|
||||
const main = createHermesAgent();
|
||||
void main();
|
||||
```
|
||||
|
||||
## Internal Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts
|
||||
├── cli.ts Binary entrypoint
|
||||
├── hermes.ts createHermesAgent, buildHermesPrompt
|
||||
├── acp-client.ts HermesAcpClient — ACP JSON-RPC over stdio
|
||||
├── session-cache.ts Session ID cache (re-exports kit helpers + isResumeDisabled)
|
||||
├── session-detail.ts Parse Hermes session JSON, store CAS detail nodes
|
||||
├── schemas.ts Hermes detail CAS schemas
|
||||
└── types.ts HermesSessionJson, HermesSessionMessage
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Uses workflow config from `~/.uncaged/workflow/config.yaml` (via agent-kit). Hermes session files are stored under the workflow storage root (see `session-detail.ts`).
|
||||
|
||||
Set `UWF_HERMES_NO_RESUME=1` to disable session resume (see `isResumeDisabled` in `session-cache.ts`).
|
||||
@@ -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;
|
||||
|
||||
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", () => {
|
||||
let client: HermesAcpClient;
|
||||
|
||||
@@ -54,7 +144,8 @@ describe("HermesAcpClient", () => {
|
||||
{ timeout: 2 * 60 * 1000 },
|
||||
);
|
||||
|
||||
it(
|
||||
// TODO(#435): flaky — depends on live LLM; mock or move to integration suite
|
||||
it.skip(
|
||||
"prompt() collects structured messages including tool calls",
|
||||
async () => {
|
||||
await client.connect(process.cwd());
|
||||
|
||||
@@ -23,7 +23,7 @@ function makeCtx(overrides: Partial<AgentContext> = {}): AgentContext {
|
||||
graph: {},
|
||||
},
|
||||
role: "developer",
|
||||
start: { prompt: "Fix the bug", workflowHash: "abc123", threadId: "t1" },
|
||||
start: { prompt: "Fix the bug", workflow: "abc123" },
|
||||
steps: [],
|
||||
store: {} as AgentContext["store"],
|
||||
outputFormatInstruction: "Use YAML frontmatter",
|
||||
@@ -49,8 +49,22 @@ describe("buildHermesPrompt", () => {
|
||||
isFirstVisit: false,
|
||||
edgePrompt: "The reviewer rejected your work. Fix the issues.",
|
||||
steps: [
|
||||
{ role: "developer", output: { summary: "Initial fix" }, agent: "uwf-hermes" },
|
||||
{ role: "reviewer", output: { approved: false }, agent: "uwf-hermes" },
|
||||
{
|
||||
role: "developer",
|
||||
output: { summary: "Initial fix" },
|
||||
agent: "uwf-hermes",
|
||||
detail: "detail-1",
|
||||
edgePrompt: "Implement the fix.",
|
||||
content: null,
|
||||
},
|
||||
{
|
||||
role: "reviewer",
|
||||
output: { approved: false },
|
||||
agent: "uwf-hermes",
|
||||
detail: "detail-2",
|
||||
edgePrompt: "Review the code.",
|
||||
content: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -66,7 +80,16 @@ describe("buildHermesPrompt", () => {
|
||||
const result = buildHermesPrompt(
|
||||
makeCtx({
|
||||
isFirstVisit: true,
|
||||
steps: [{ role: "developer", output: { done: true }, agent: "uwf-hermes" }],
|
||||
steps: [
|
||||
{
|
||||
role: "developer",
|
||||
output: { done: true },
|
||||
agent: "uwf-hermes",
|
||||
detail: "detail-1",
|
||||
edgePrompt: "First attempt.",
|
||||
content: null,
|
||||
},
|
||||
],
|
||||
edgePrompt: "Retry with a fresh approach.",
|
||||
}),
|
||||
);
|
||||
@@ -75,4 +98,90 @@ describe("buildHermesPrompt", () => {
|
||||
expect(result).toContain("Retry with a fresh approach.");
|
||||
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!");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,8 @@ describe("HermesAcpClient cross-process resume", () => {
|
||||
clients.length = 0;
|
||||
});
|
||||
|
||||
it(
|
||||
// TODO(#435): flaky — depends on live LLM; mock or move to integration suite
|
||||
it.skip(
|
||||
"resume() after close — second prompt returns non-empty text",
|
||||
async () => {
|
||||
// --- Client A: first run ---
|
||||
|
||||
@@ -245,73 +245,75 @@ export class HermesAcpClient {
|
||||
// ---- Session update → structured messages ----
|
||||
|
||||
private handleSessionUpdate(update: Record<string, unknown>): void {
|
||||
const updateType = update.sessionUpdate as string;
|
||||
|
||||
switch (updateType) {
|
||||
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);
|
||||
}
|
||||
switch (update.sessionUpdate as string) {
|
||||
case "agent_message_chunk":
|
||||
this.handleAgentMessageChunk(update);
|
||||
break;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
case "agent_thought_chunk":
|
||||
this.handleAgentThoughtChunk(update);
|
||||
break;
|
||||
}
|
||||
|
||||
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();
|
||||
case "tool_call":
|
||||
this.handleToolCall(update);
|
||||
break;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
case "tool_call_update":
|
||||
this.handleToolCallUpdate(update);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
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. */
|
||||
private flushAssistantMessage(): void {
|
||||
const text = this.messageChunks.join("");
|
||||
|
||||
@@ -14,53 +14,39 @@ import { storeHermesSessionDetail } from "./session-detail.js";
|
||||
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
function buildHistorySummary(steps: AgentContext["steps"]): string {
|
||||
if (steps.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const lines: string[] = ["## Previous Steps"];
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
if (step === undefined) {
|
||||
continue;
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(`### Step ${i + 1}: ${step.role}`);
|
||||
lines.push(`Output: ${JSON.stringify(step.output)}`);
|
||||
lines.push(`Agent: ${step.agent}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildInitialPrompt(ctx: AgentContext): string {
|
||||
const roleDef = ctx.workflow.roles[ctx.role];
|
||||
const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
|
||||
/** Assemble system prompt, task, and prior step outputs for Hermes. */
|
||||
export function buildHermesPrompt(ctx: AgentContext): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (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) {
|
||||
const parts: string[] = [];
|
||||
if (ctx.outputFormatInstruction !== "") {
|
||||
parts.push(ctx.outputFormatInstruction, "");
|
||||
}
|
||||
// Re-entry: show only steps since last visit, meta only
|
||||
parts.push(buildContinuationPrompt(ctx.steps, ctx.role, ctx.edgePrompt));
|
||||
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(
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
// Re-export session cache from the shared agent-kit package.
|
||||
export { getCachedSessionId, setCachedSessionId } from "@uncaged/workflow-agent-kit";
|
||||
// Re-export session cache from the shared agent-kit package with agent name injected.
|
||||
|
||||
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 {
|
||||
// Hermes ACP session/resume is broken: _restore fails for custom providers
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# @uncaged/workflow-agent-kit
|
||||
|
||||
Agent framework — `createAgent` factory, context builder, frontmatter fast-path, and LLM extract pipeline.
|
||||
|
||||
## Overview
|
||||
|
||||
Layer 2 agent framework. Provides the standard entrypoint for all agent CLIs: parse `<thread-id> <role>` from argv, load thread/workflow context from CAS, invoke the agent's `run`/`continue` functions, validate output via frontmatter fast-path or LLM extract, and write a `StepNodePayload` to CAS.
|
||||
|
||||
Also exports prompt builders, config/storage helpers, and session ID caching for multi-turn agents.
|
||||
|
||||
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`, `dotenv`, `yaml`
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @uncaged/workflow-agent-kit
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
All exports come from `src/index.ts`.
|
||||
|
||||
### Agent factory
|
||||
|
||||
```typescript
|
||||
function createAgent(options: AgentOptions): () => Promise<void>
|
||||
|
||||
type AgentOptions = {
|
||||
name: string;
|
||||
run: AgentRunFn;
|
||||
continue: AgentContinueFn;
|
||||
};
|
||||
|
||||
type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
|
||||
type AgentContinueFn = (
|
||||
sessionId: string,
|
||||
message: string,
|
||||
store: AgentContext["store"],
|
||||
) => Promise<AgentRunResult>;
|
||||
|
||||
type AgentRunResult = {
|
||||
output: string;
|
||||
detailHash: string;
|
||||
sessionId: string;
|
||||
};
|
||||
```
|
||||
|
||||
Agent CLIs call `createAgent(...)` and invoke the returned function as `main()`.
|
||||
|
||||
### Context
|
||||
|
||||
```typescript
|
||||
function buildContext(threadId: ThreadId, role: string): Promise<AgentContext>
|
||||
function buildContextWithMeta(
|
||||
threadId: ThreadId,
|
||||
role: string,
|
||||
): Promise<AgentContext & { meta: BuildContextMeta }>
|
||||
|
||||
type AgentContext = ModeratorContext & {
|
||||
threadId: ThreadId;
|
||||
role: string;
|
||||
store: Store;
|
||||
workflow: WorkflowPayload;
|
||||
outputFormatInstruction: string;
|
||||
edgePrompt: string;
|
||||
isFirstVisit: boolean;
|
||||
};
|
||||
|
||||
type BuildContextMeta = {
|
||||
storageRoot: string;
|
||||
store: Store;
|
||||
schemas: AgentStore["schemas"];
|
||||
headHash: CasRef;
|
||||
chain: ChainState;
|
||||
};
|
||||
```
|
||||
|
||||
Requires `UWF_EDGE_PROMPT` in the environment (set by `uwf thread step`).
|
||||
|
||||
### Prompt builders
|
||||
|
||||
```typescript
|
||||
function buildRolePrompt(role: RoleDefinition): string
|
||||
function buildOutputFormatInstruction(schema: JSONSchema): string
|
||||
function buildContinuationPrompt(
|
||||
steps: StepContext[],
|
||||
role: string,
|
||||
edgePrompt: string,
|
||||
options?: { includeContent?: boolean; quota?: number },
|
||||
): string
|
||||
```
|
||||
|
||||
### Extract pipeline
|
||||
|
||||
```typescript
|
||||
function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias
|
||||
function resolveModel(config: WorkflowConfig, alias: ModelAlias): ResolvedLlmProvider
|
||||
function extract(
|
||||
rawOutput: string,
|
||||
outputSchema: CasRef,
|
||||
config: WorkflowConfig,
|
||||
): Promise<ExtractResult>
|
||||
|
||||
type ResolvedLlmProvider = { baseUrl: string; apiKey: string; model: string };
|
||||
type ExtractResult = { value: unknown; hash: CasRef };
|
||||
```
|
||||
|
||||
### Frontmatter fast-path
|
||||
|
||||
```typescript
|
||||
function tryFrontmatterFastPath(
|
||||
rawOutput: string,
|
||||
outputSchema: CasRef,
|
||||
store: Store,
|
||||
): Promise<FrontmatterFastPathResult | null>
|
||||
|
||||
type FrontmatterFastPathResult = { body: string; outputHash: CasRef };
|
||||
```
|
||||
|
||||
### Session cache
|
||||
|
||||
```typescript
|
||||
function getCachedSessionId(threadId: ThreadId, role: string): Promise<string | null>
|
||||
function setCachedSessionId(
|
||||
threadId: ThreadId,
|
||||
role: string,
|
||||
sessionId: string,
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
### Config and storage
|
||||
|
||||
```typescript
|
||||
function getConfigPath(storageRoot: string): string
|
||||
function getEnvPath(storageRoot: string): string
|
||||
function resolveStorageRoot(): string
|
||||
function loadWorkflowConfig(storageRoot: string): Promise<WorkflowConfig>
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { createAgent, buildRolePrompt } from "@uncaged/workflow-agent-kit";
|
||||
import type { AgentContext, AgentRunResult } from "@uncaged/workflow-agent-kit";
|
||||
|
||||
async function run(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
const prompt = buildRolePrompt(ctx.workflow.roles[ctx.role]!);
|
||||
// ... spawn external process, capture output ...
|
||||
return { output: markdown, detailHash: "...", sessionId: "..." };
|
||||
}
|
||||
|
||||
async function continueSession(
|
||||
sessionId: string,
|
||||
message: string,
|
||||
): Promise<AgentRunResult> {
|
||||
// ... continue multi-turn session ...
|
||||
return { output: markdown, detailHash: "...", sessionId };
|
||||
}
|
||||
|
||||
export const main = createAgent({ name: "my-agent", run, continue: continueSession });
|
||||
```
|
||||
|
||||
## Internal Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts
|
||||
├── run.ts createAgent entrypoint
|
||||
├── context.ts Thread chain walk, AgentContext builder
|
||||
├── extract.ts LLM structured extract fallback
|
||||
├── frontmatter.ts Frontmatter fast-path validation
|
||||
├── build-role-prompt.ts Role definition → prompt text
|
||||
├── build-output-format-instruction.ts
|
||||
├── build-continuation-prompt.ts
|
||||
├── session-cache.ts Per-thread/session ID persistence
|
||||
├── storage.ts CAS store, config, threads index
|
||||
├── schemas.ts Agent CAS schema registration
|
||||
└── types.ts AgentContext, AgentOptions, etc.
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Reads `config.yaml` and `.env` from the workflow storage root (`~/.uncaged/workflow` by default). See `@uncaged/workflow-protocol` for `WorkflowConfig` shape. Set via `uwf setup`.
|
||||
@@ -7,6 +7,8 @@ const reviewerStep: StepContext = {
|
||||
output: { approved: false, comments: "Missing tests" },
|
||||
detail: "2MXBG6PN4A8JR",
|
||||
agent: "uwf-hermes",
|
||||
edgePrompt: "Review the developer's work.",
|
||||
content: null,
|
||||
};
|
||||
|
||||
const developerStep: StepContext = {
|
||||
@@ -14,6 +16,8 @@ const developerStep: StepContext = {
|
||||
output: { filesChanged: ["src/app.ts"], summary: "Initial fix" },
|
||||
detail: "1VPBG9SM5E7WK",
|
||||
agent: "uwf-hermes",
|
||||
edgePrompt: "Implement the fix.",
|
||||
content: null,
|
||||
};
|
||||
|
||||
describe("buildContinuationPrompt", () => {
|
||||
@@ -26,6 +30,8 @@ describe("buildContinuationPrompt", () => {
|
||||
output: { plan: "revise approach" },
|
||||
detail: "7BQST3VW9F2MA",
|
||||
agent: "uwf-hermes",
|
||||
edgePrompt: "Revise the plan.",
|
||||
content: null,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -67,4 +73,162 @@ describe("buildContinuationPrompt", () => {
|
||||
expect(result).toContain("## Moderator Instruction");
|
||||
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";
|
||||
|
||||
function formatStep(step: StepContext, stepNumber: number): string {
|
||||
return [
|
||||
function formatStep(step: StepContext, stepNumber: number, includeContent: boolean): string {
|
||||
const lines = [
|
||||
`### Step ${stepNumber}: ${step.role}`,
|
||||
`Output: ${JSON.stringify(step.output)}`,
|
||||
`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 {
|
||||
@@ -18,6 +27,45 @@ function findLastRoleIndex(steps: StepContext[], role: string): number {
|
||||
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.
|
||||
*
|
||||
@@ -28,7 +76,11 @@ export function buildContinuationPrompt(
|
||||
steps: StepContext[],
|
||||
role: string,
|
||||
edgePrompt: string,
|
||||
options?: BuildContinuationPromptOptions,
|
||||
): string {
|
||||
const includeContent = options?.includeContent ?? false;
|
||||
const quota = options?.quota ?? Number.POSITIVE_INFINITY;
|
||||
|
||||
const lastIndex = findLastRoleIndex(steps, role);
|
||||
const sinceSteps = lastIndex >= 0 ? steps.slice(lastIndex + 1) : steps;
|
||||
|
||||
@@ -37,13 +89,25 @@ export function buildContinuationPrompt(
|
||||
if (sinceSteps.length > 0) {
|
||||
parts.push("## What Happened Since Your Last Turn");
|
||||
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) {
|
||||
continue;
|
||||
}
|
||||
parts.push("");
|
||||
parts.push(formatStep(step, baseStepNumber + i));
|
||||
parts.push(formatStep(step, baseStepNumber + i, includeContent));
|
||||
}
|
||||
parts.push("");
|
||||
}
|
||||
|
||||
@@ -21,14 +21,6 @@ function fail(message: string): never {
|
||||
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 {
|
||||
const headNode = store.get(headHash);
|
||||
if (headNode === null) {
|
||||
@@ -90,6 +82,38 @@ function expandOutput(store: Store, outputRef: CasRef): unknown {
|
||||
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(
|
||||
store: Store,
|
||||
stepsNewestFirst: StepNodePayload[],
|
||||
@@ -97,11 +121,14 @@ async function buildHistory(
|
||||
const chronological = [...stepsNewestFirst].reverse();
|
||||
const history: StepContext[] = [];
|
||||
for (const step of chronological) {
|
||||
const content = extractStepContent(store, step.detail);
|
||||
history.push({
|
||||
role: step.role,
|
||||
output: expandOutput(store, step.output),
|
||||
detail: step.detail,
|
||||
agent: step.agent,
|
||||
edgePrompt: step.edgePrompt ?? "",
|
||||
content,
|
||||
});
|
||||
}
|
||||
return history;
|
||||
@@ -122,7 +149,11 @@ async function loadWorkflow(store: Store, schemas: AgentStore["schemas"], workfl
|
||||
* Build agent execution context from thread head in threads.yaml.
|
||||
* 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 agentStore = await createAgentStore(storageRoot);
|
||||
const { store, schemas } = agentStore;
|
||||
@@ -141,7 +172,6 @@ export async function buildContext(threadId: ThreadId, role: string): Promise<Ag
|
||||
}
|
||||
|
||||
const steps = await buildHistory(store, chain.stepsNewestFirst);
|
||||
const edgePrompt = readEdgePrompt();
|
||||
const isFirstVisit = !steps.some((s) => s.role === role);
|
||||
|
||||
return {
|
||||
@@ -171,6 +201,7 @@ export type BuildContextMeta = {
|
||||
export async function buildContextWithMeta(
|
||||
threadId: ThreadId,
|
||||
role: string,
|
||||
edgePrompt: string,
|
||||
): Promise<AgentContext & { meta: BuildContextMeta }> {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
const agentStore = await createAgentStore(storageRoot);
|
||||
@@ -190,7 +221,6 @@ export async function buildContextWithMeta(
|
||||
}
|
||||
|
||||
const steps = await buildHistory(store, chain.stepsNewestFirst);
|
||||
const edgePrompt = readEdgePrompt();
|
||||
const isFirstVisit = !steps.some((s) => s.role === role);
|
||||
|
||||
return {
|
||||
|
||||
@@ -12,8 +12,8 @@ export {
|
||||
export type { FrontmatterFastPathResult } from "./frontmatter.js";
|
||||
export { tryFrontmatterFastPath } from "./frontmatter.js";
|
||||
export { createAgent } from "./run.js";
|
||||
export { getCachedSessionId, getCachePath, setCachedSessionId } from "./session-cache.js";
|
||||
export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
|
||||
export { getCachedSessionId, setCachedSessionId } from "./session-cache.js";
|
||||
export type {
|
||||
AgentContext,
|
||||
AgentContinueFn,
|
||||
|
||||
@@ -22,16 +22,24 @@ function agentLabel(name: string): string {
|
||||
return `uwf-${name}`;
|
||||
}
|
||||
|
||||
function parseArgv(argv: string[]): { threadId: ThreadId; role: string } {
|
||||
const threadId = argv[2];
|
||||
const role = argv[3];
|
||||
if (threadId === undefined || threadId === "") {
|
||||
fail("usage: <agent-cli> <thread-id> <role>");
|
||||
const USAGE = "usage: <agent-cli> --thread <id> --role <role> --prompt <text>";
|
||||
|
||||
function getNamedArg(argv: string[], name: string): string {
|
||||
const idx = argv.indexOf(name);
|
||||
if (idx === -1 || idx + 1 >= argv.length) {
|
||||
return "";
|
||||
}
|
||||
if (role === undefined || role === "") {
|
||||
fail("usage: <agent-cli> <thread-id> <role>");
|
||||
}
|
||||
return { threadId: threadId as ThreadId, role };
|
||||
return argv[idx + 1];
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -50,6 +58,7 @@ async function writeStepNode(options: {
|
||||
outputHash: CasRef;
|
||||
detailHash: CasRef;
|
||||
agentName: string;
|
||||
edgePrompt: string;
|
||||
}): Promise<CasRef> {
|
||||
const payload: StepNodePayload = {
|
||||
start: options.startHash,
|
||||
@@ -58,6 +67,7 @@ async function writeStepNode(options: {
|
||||
output: options.outputHash,
|
||||
detail: options.detailHash,
|
||||
agent: options.agentName,
|
||||
edgePrompt: options.edgePrompt,
|
||||
};
|
||||
const hash = await options.store.put(options.schemas.stepNode, payload);
|
||||
const node = options.store.get(hash);
|
||||
@@ -95,16 +105,17 @@ async function persistStep(options: {
|
||||
outputHash: options.outputHash,
|
||||
detailHash: options.detailHash,
|
||||
agentName: options.agentName,
|
||||
edgePrompt: options.ctx.edgePrompt,
|
||||
});
|
||||
}
|
||||
|
||||
export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
return async function main(): Promise<void> {
|
||||
const { threadId, role } = parseArgv(process.argv);
|
||||
const { threadId, role, prompt } = parseArgv(process.argv);
|
||||
const storageRoot = resolveStorageRoot();
|
||||
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];
|
||||
if (roleDef === undefined) {
|
||||
@@ -118,6 +129,11 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
|
||||
let agentResult = await runWithMessage("agent run failed", () => options.run(ctx));
|
||||
|
||||
// Preserve the primary detail from the first run — it contains the full
|
||||
// tool-call turn history. Continuation retries only fix frontmatter
|
||||
// formatting and their 1-turn detail is not meaningful.
|
||||
const primaryDetailHash = agentResult.detailHash;
|
||||
|
||||
// Try to extract frontmatter; retry via continue if it fails
|
||||
let outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
||||
|
||||
@@ -144,7 +160,7 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
const stepHash = await persistStep({
|
||||
ctx,
|
||||
outputHash,
|
||||
detailHash: agentResult.detailHash,
|
||||
detailHash: primaryDetailHash,
|
||||
agentName: agentLabel(options.name),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import type { ThreadId } from "@uncaged/workflow-protocol";
|
||||
@@ -8,8 +8,8 @@ import { resolveStorageRoot } from "./storage.js";
|
||||
|
||||
type SessionCache = Record<string, string>;
|
||||
|
||||
function getCachePath(): string {
|
||||
return join(resolveStorageRoot(), "cache", "agent-sessions.json");
|
||||
export function getCachePath(agentName: string): string {
|
||||
return join(resolveStorageRoot(), "cache", `${agentName}-sessions.json`);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async function readCache(): Promise<SessionCache> {
|
||||
const path = getCachePath();
|
||||
async function readCache(agentName: string): Promise<SessionCache> {
|
||||
const path = getCachePath(agentName);
|
||||
try {
|
||||
const text = await readFile(path, "utf8");
|
||||
const raw = JSON.parse(text) as unknown;
|
||||
@@ -40,36 +40,45 @@ async function readCache(): Promise<SessionCache> {
|
||||
if (err.code === "ENOENT") {
|
||||
return {};
|
||||
}
|
||||
// Treat JSON parse errors as empty cache
|
||||
if (err.name === "SyntaxError") {
|
||||
return {};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCache(cache: SessionCache): Promise<void> {
|
||||
const path = getCachePath();
|
||||
async function writeCache(agentName: string, cache: SessionCache): Promise<void> {
|
||||
const path = getCachePath(agentName);
|
||||
const dir = dirname(path);
|
||||
await mkdir(dir, { recursive: true });
|
||||
// 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.
|
||||
// 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 rename(tmpPath, path);
|
||||
}
|
||||
|
||||
/** Read the cached session ID for a thread+role pair. */
|
||||
export async function getCachedSessionId(threadId: ThreadId, role: string): Promise<string | null> {
|
||||
const cache = await readCache();
|
||||
export async function getCachedSessionId(
|
||||
agentName: string,
|
||||
threadId: ThreadId,
|
||||
role: string,
|
||||
): Promise<string | null> {
|
||||
const cache = await readCache(agentName);
|
||||
const sessionId = cache[cacheKey(threadId, role)];
|
||||
return sessionId ?? null;
|
||||
}
|
||||
|
||||
/** Write the session ID for a thread+role pair into the cache. */
|
||||
export async function setCachedSessionId(
|
||||
agentName: string,
|
||||
threadId: ThreadId,
|
||||
role: string,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
const cache = await readCache();
|
||||
const cache = await readCache(agentName);
|
||||
cache[cacheKey(threadId, role)] = sessionId;
|
||||
await writeCache(cache);
|
||||
await writeCache(agentName, cache);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export type AgentContext = ModeratorContext & {
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
edgePrompt: string;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# @uncaged/workflow-dashboard
|
||||
|
||||
Web graph editor for visualizing and editing workflow YAML definitions.
|
||||
|
||||
## Overview
|
||||
|
||||
A private alpha web app (not part of the runtime engine stack). Provides a React + `@xyflow/react` canvas for editing workflow roles, conditions, and graph transitions. Uses `@uncaged/workflow-protocol` types for validation and YAML round-tripping.
|
||||
|
||||
Planned integration: local `uwf connect` over WebSocket to sync YAML between CLI and the browser editor. The REST API and Elysia backend are currently stubs for development.
|
||||
|
||||
**Dependencies:** `@uncaged/workflow-protocol`, `@xyflow/react`, React 19, react-router v7, Vite 8, Tailwind CSS v4, Elysia
|
||||
|
||||
## Installation
|
||||
|
||||
Monorepo-only ( `"private": true` ). Not published to npm.
|
||||
|
||||
```bash
|
||||
cd packages/workflow-dashboard
|
||||
bun install --no-cache
|
||||
```
|
||||
|
||||
## CLI Usage
|
||||
|
||||
Start the Vite dev server (port 3000):
|
||||
|
||||
```bash
|
||||
cd packages/workflow-dashboard
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Build for production:
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
|
||||
Open `http://localhost:3000` in a browser.
|
||||
|
||||
## Internal Structure
|
||||
|
||||
```
|
||||
workflow-dashboard/
|
||||
├── server.ts Vite dev server entry (port 3000)
|
||||
├── vite.config.ts Vite + React + Tailwind + Elysia plugin
|
||||
├── vite-dev.ts Custom Vite plugin
|
||||
├── index.html
|
||||
├── components.json shadcn configuration
|
||||
├── server/
|
||||
│ ├── api.ts Elysia REST API (health + workflow CRUD stub)
|
||||
│ └── workflow.ts Workflow file read/write + format conversion
|
||||
└── src/
|
||||
├── main.tsx React DOM entry
|
||||
├── app.tsx Root layout
|
||||
├── router.tsx Hash-mode routes
|
||||
├── index.css
|
||||
├── lib/utils.ts Tailwind cn() helper
|
||||
├── components/ui/ shadcn components (button, card, dialog, input, …)
|
||||
├── pages/
|
||||
│ ├── home.tsx Workflow list
|
||||
│ ├── detail.tsx Workflow detail view
|
||||
│ └── editor.tsx Full editor page
|
||||
└── editor/ Core graph editor
|
||||
├── flow.tsx FlowEditor component
|
||||
├── context.tsx State (useSyncExternalStore + Immer)
|
||||
├── injection.ts DI container
|
||||
├── type.ts Internal editor types
|
||||
├── model/ Node/edge state model
|
||||
├── nodes/ Start, role, end node components
|
||||
├── edges/ Conditional edge rendering
|
||||
├── panel/ Toolbar, add/edit panels
|
||||
├── trans/ YAML ↔ graph conversion (trans-in, trans-out, validate)
|
||||
├── layout/ Auto-layout
|
||||
└── utils/ Event helpers, click-outside hook
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Setting | Default | Notes |
|
||||
|---------|---------|-------|
|
||||
| Dev server port | `3000` | Set in `server.ts` |
|
||||
| Workflow storage (dev) | `tmp/workflow/` | YAML files during development |
|
||||
| Path alias | `@/` → `src/` | Configured in `vite.config.ts` |
|
||||
|
||||
No library API — this package is an application, not importable as a module.
|
||||
@@ -6,8 +6,8 @@
|
||||
<title>Workflow UI</title>
|
||||
<link rel="stylesheet" href="./src/index.css" />
|
||||
<script>
|
||||
(function () {
|
||||
var t = localStorage.getItem("theme");
|
||||
(() => {
|
||||
const t = localStorage.getItem("theme");
|
||||
if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches)) {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
|
||||
@@ -31,8 +31,10 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/ui": "^4.1.7",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^8.0.13"
|
||||
"vite": "^8.0.13",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,3 @@ const server = await createServer({
|
||||
});
|
||||
|
||||
await server.listen();
|
||||
|
||||
// biome-ignore lint/nursery/noConsole: CLI user-facing output
|
||||
console.log(`Workflow UI running at http://localhost:${PORT}`);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import type { WorkFlowSteps } from "../shared/types.ts";
|
||||
import {
|
||||
listWorkflows,
|
||||
getWorkflow,
|
||||
createWorkflow,
|
||||
saveWorkflow,
|
||||
deleteWorkflow,
|
||||
getWorkflow,
|
||||
listWorkflows,
|
||||
saveWorkflow,
|
||||
} from "./workflow.ts";
|
||||
|
||||
export function createApi() {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { readdir, readFile, writeFile, unlink, mkdir } from "node:fs/promises";
|
||||
import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { RoleDefinition, Transition, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import YAML from "yaml";
|
||||
import type {
|
||||
WorkflowPayload,
|
||||
RoleDefinition,
|
||||
Transition,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import type { WorkFlowSteps, WorkFlowTransition, WorkflowSummary } from "../shared/types.ts";
|
||||
|
||||
const WORKFLOW_DIR = join(import.meta.dirname, "..", "tmp", "workflow");
|
||||
@@ -67,7 +63,7 @@ function stepsToPayload(name: string, description: string, steps: WorkFlowSteps)
|
||||
let condName: string | null = null;
|
||||
if (t.condition) {
|
||||
if (expressionToName.has(t.condition)) {
|
||||
condName = expressionToName.get(t.condition)!;
|
||||
condName = expressionToName.get(t.condition) ?? null;
|
||||
} else {
|
||||
condName = `cond${condIdx++}`;
|
||||
expressionToName.set(t.condition, condName);
|
||||
@@ -90,7 +86,7 @@ function stepsToPayload(name: string, description: string, steps: WorkFlowSteps)
|
||||
|
||||
if (steps.length > 0) {
|
||||
const firstRole = steps[0].role.name;
|
||||
graph["$START"] = [
|
||||
graph.$START = [
|
||||
{
|
||||
role: firstRole,
|
||||
condition: null,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
@@ -37,8 +37,8 @@ const buttonVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
@@ -52,7 +52,7 @@ function Button({
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({
|
||||
className,
|
||||
@@ -13,11 +13,11 @@ function Card({
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -26,11 +26,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -39,11 +39,11 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -53,20 +53,17 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -76,7 +73,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -85,19 +82,11 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
||||
|
||||
@@ -1,40 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
@@ -43,7 +39,7 @@ function DialogContent({
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
@@ -52,7 +48,7 @@ function DialogContent({
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -60,32 +56,21 @@ function DialogContent({
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
render={<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" />}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
<div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
@@ -94,54 +79,46 @@ function DialogFooter({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>Close</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
className={cn("font-heading text-base leading-none font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -155,4 +132,4 @@ export {
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
@@ -10,11 +10,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Input }
|
||||
export { Input };
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import * as React from "react"
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/noLabelWithoutControl: generic Label component; control association handled by consumer
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Label }
|
||||
export { Label };
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
function Separator({ className, orientation = "horizontal", ...props }: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
export { Separator };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
@@ -8,11 +8,11 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
export { Textarea };
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { createContext, useMemo, useSyncExternalStore, useContext, useLayoutEffect } from 'react';
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
import { useReactFlow, ReactFlowInstance } from '@xyflow/react';
|
||||
import type { AnyWorkNode } from './type';
|
||||
import { type ReactFlowInstance, useReactFlow } from "@xyflow/react";
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
import { createContext, useContext, useLayoutEffect, useMemo, useSyncExternalStore } from "react";
|
||||
import type { AnyWorkNode } from "./type";
|
||||
|
||||
type Reduce<T> = (data: T) => T;
|
||||
type Setter<T> = (ch: Reduce<T> | T) => void;
|
||||
|
||||
interface State<T, A> {
|
||||
interface State<T, A> {
|
||||
readonly get: () => T;
|
||||
readonly set: Setter<T>;
|
||||
readonly use: () => T;
|
||||
@@ -15,6 +15,7 @@ interface State<T, A> {
|
||||
readonly onlyView: boolean;
|
||||
}
|
||||
type Use = <T, A>(sub: SubModel<T, A>) => [T, A];
|
||||
// biome-ignore lint/suspicious/noExplicitAny: UseV intentionally erases the action type
|
||||
type UseV = <T>(sub: SubModel<T, any>) => T;
|
||||
type Create<T, A> = (set: Setter<T>, get: () => T, model: Model) => A;
|
||||
|
||||
@@ -24,10 +25,12 @@ export function generate<T>(val: T) {
|
||||
const listener = new Set<VoidFunction>();
|
||||
const get = () => val;
|
||||
function set(ch: T | ((prev: T) => T)) {
|
||||
const next = (typeof ch === 'function') ? (ch as (prev: T) => T)(val) : ch;
|
||||
const next = typeof ch === "function" ? (ch as (prev: T) => T)(val) : ch;
|
||||
if (Object.is(val, next)) return;
|
||||
val = next;
|
||||
listener.forEach(call => call());
|
||||
for (const call of listener) {
|
||||
call();
|
||||
}
|
||||
}
|
||||
const listen = (call: VoidFunction) => {
|
||||
listener.add(call);
|
||||
@@ -38,21 +41,26 @@ export function generate<T>(val: T) {
|
||||
}
|
||||
|
||||
class SubModel<T, A> {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
private make: () => T,
|
||||
private create: Create<T, A>,
|
||||
private onlyView = false,
|
||||
) {}
|
||||
public readonly name: string;
|
||||
private readonly make: () => T;
|
||||
private readonly create: Create<T, A>;
|
||||
private readonly onlyView: boolean;
|
||||
|
||||
constructor(name: string, _make: () => T, _create: Create<T, A>, _onlyView = false) {
|
||||
this.name = name;
|
||||
this.make = _make;
|
||||
this.create = _create;
|
||||
this.onlyView = _onlyView;
|
||||
}
|
||||
|
||||
public gen(model: Model): State<T, A> {
|
||||
const { make, create, onlyView } = this;
|
||||
const { get, set, use, listen } = generate(make());
|
||||
const actions = create(set, get, model);
|
||||
return { get, set, use, listen, actions, onlyView };
|
||||
const { get, set, use, listen } = generate(this.make());
|
||||
const actions = this.create(set, get, model);
|
||||
return { get, set, use, listen, actions, onlyView: this.onlyView };
|
||||
}
|
||||
|
||||
use(): [T, A] {
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: use() is called as a hook by consumers
|
||||
const { query } = useContext(Context);
|
||||
const { use, actions } = query(this);
|
||||
return [use(), actions];
|
||||
@@ -67,20 +75,27 @@ class SubModel<T, A> {
|
||||
}
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: snapshot data is heterogeneous
|
||||
type Snapshot = [name: string, data: any];
|
||||
class Model {
|
||||
private ustack: Snapshot[][] = [];
|
||||
private rstack: Snapshot[][] = [];
|
||||
private transaction = 0;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: backup stores heterogeneous state values
|
||||
private backup = new Map<string, any>();
|
||||
public flow = {} as ReactFlowInstance<AnyWorkNode>;
|
||||
private stackListeners = new Set<() => void>();
|
||||
public readonly stackState: readonly [boolean, boolean] = [false, false];
|
||||
|
||||
constructor(
|
||||
private readonly store: Map<string, State<any, any>>,
|
||||
public readonly use: Use,
|
||||
) {}
|
||||
// biome-ignore lint/suspicious/noExplicitAny: store holds heterogeneous state types
|
||||
private readonly store: Map<string, State<any, any>>;
|
||||
public readonly use: Use;
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: store holds heterogeneous state types
|
||||
constructor(store: Map<string, State<any, any>>, use: Use) {
|
||||
this.store = store;
|
||||
this.use = use;
|
||||
}
|
||||
|
||||
public reset() {
|
||||
this.ustack = [];
|
||||
@@ -93,12 +108,14 @@ class Model {
|
||||
public readonly listenStackState = (cb: () => void) => {
|
||||
this.stackListeners.add(cb);
|
||||
return () => this.stackListeners.delete(cb);
|
||||
}
|
||||
};
|
||||
|
||||
private triggerStackState() {
|
||||
// @ts-expect-error
|
||||
this.stackState = [this.canUndo(), this.canRedo()];
|
||||
this.stackListeners.forEach(call => call());
|
||||
for (const call of this.stackListeners) {
|
||||
call();
|
||||
}
|
||||
}
|
||||
|
||||
private getStackState = () => this.stackState;
|
||||
@@ -108,13 +125,11 @@ class Model {
|
||||
}
|
||||
|
||||
public log() {
|
||||
console.log('undo stack:', this.ustack);
|
||||
console.log('redo stack:', this.rstack);
|
||||
// biome-ignore lint/suspicious/noExplicitAny: debug log accumulates heterogeneous values
|
||||
const snapshots: Record<string, any> = {};
|
||||
this.store.forEach((state, name) => {
|
||||
for (const [name, state] of this.store) {
|
||||
snapshots[name] = state.get();
|
||||
});
|
||||
console.log('current state:', snapshots);
|
||||
}
|
||||
}
|
||||
|
||||
public undo() {
|
||||
@@ -122,11 +137,13 @@ class Model {
|
||||
const item = ustack.pop();
|
||||
if (!item) return;
|
||||
const step: Snapshot[] = [];
|
||||
item.forEach(([name, data]) => {
|
||||
const { get, set } = store.get(name)!;
|
||||
for (const [name, data] of item) {
|
||||
const entry = store.get(name);
|
||||
if (!entry) continue;
|
||||
const { get, set } = entry;
|
||||
step.push([name, get()]);
|
||||
set(data);
|
||||
});
|
||||
}
|
||||
rstack.push(step);
|
||||
this.triggerStackState();
|
||||
}
|
||||
@@ -136,11 +153,13 @@ class Model {
|
||||
const item = rstack.pop();
|
||||
if (!item) return;
|
||||
const step: Snapshot[] = [];
|
||||
item.forEach(([name, data]) => {
|
||||
const { get, set } = store.get(name)!;
|
||||
for (const [name, data] of item) {
|
||||
const entry = store.get(name);
|
||||
if (!entry) continue;
|
||||
const { get, set } = entry;
|
||||
step.push([name, get()]);
|
||||
set(data);
|
||||
});
|
||||
}
|
||||
ustack.push(step);
|
||||
this.triggerStackState();
|
||||
}
|
||||
@@ -156,10 +175,10 @@ class Model {
|
||||
public startTransaction() {
|
||||
if (this.transaction === 0) {
|
||||
this.backup.clear();
|
||||
this.store.forEach((state, name) => {
|
||||
if (state.onlyView) return;
|
||||
for (const [name, state] of this.store) {
|
||||
if (state.onlyView) continue;
|
||||
this.backup.set(name, state.get());
|
||||
});
|
||||
}
|
||||
}
|
||||
this.transaction += 1;
|
||||
return this.endTransaction;
|
||||
@@ -170,24 +189,26 @@ class Model {
|
||||
this.transaction -= 1;
|
||||
if (this.transaction === 0) {
|
||||
const changes: Snapshot[] = [];
|
||||
this.store.forEach((state, name) => {
|
||||
if (state.onlyView) return;
|
||||
for (const [name, state] of this.store) {
|
||||
if (state.onlyView) continue;
|
||||
const before = this.backup.get(name);
|
||||
if (Object.is(before, state.get())) return;
|
||||
if (Object.is(before, state.get())) continue;
|
||||
changes.push([name, before]);
|
||||
});
|
||||
}
|
||||
this.backup.clear();
|
||||
if (changes.length === 0) return;
|
||||
this.ustack.push(changes);
|
||||
this.rstack.length = 0;
|
||||
this.triggerStackState();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function build() {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: store holds heterogeneous state types
|
||||
const store = new Map<string, State<any, any>>();
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: memo cache stores heterogeneous values
|
||||
const mem: Record<string, any> = {};
|
||||
function use<T, A>(m: SubModel<T, A>): [T, A] {
|
||||
const state = query(m);
|
||||
@@ -195,8 +216,8 @@ function build() {
|
||||
}
|
||||
|
||||
const model = new Model(store, use);
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// @ts-ignore
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
// @ts-expect-error
|
||||
window.__md__ = model;
|
||||
}
|
||||
|
||||
@@ -206,9 +227,9 @@ function build() {
|
||||
const created = m.gen(model);
|
||||
store.set(m.name, created);
|
||||
return created;
|
||||
};
|
||||
}
|
||||
|
||||
return { query, model, mem, use }
|
||||
return { query, model, mem, use };
|
||||
}
|
||||
|
||||
const Context = createContext(build());
|
||||
@@ -222,24 +243,28 @@ export function RegisterFlowToContext() {
|
||||
const instance = useReactFlow<AnyWorkNode>();
|
||||
useLayoutEffect(() => {
|
||||
model.flow = instance;
|
||||
}, [instance]);
|
||||
}, [instance, model]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ModelProvider: FC<PropsWithChildren> = (p) => (
|
||||
<Context.Provider value={useMemo(build, [])}>
|
||||
{p.children}
|
||||
</Context.Provider>
|
||||
<Context.Provider value={useMemo(build, [])}>{p.children}</Context.Provider>
|
||||
);
|
||||
|
||||
function defineModel<T, A>(name: string, make: () => T, create: Create<T, A>) {
|
||||
return new SubModel<T, A>(name, make, create);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: default create returns setter directly
|
||||
const defaultCreate: Create<any, Setter<any>> = (set) => set;
|
||||
function defineView<T, A>(name: string, make: () => T, create: Create<T, A>): SubModel<T, A>
|
||||
function defineView<T>(name: string, make: () => T): SubModel<T, Setter<T>>
|
||||
function defineView<T>(name: string, make: () => T, create?: any): any {
|
||||
function defineView<T, A>(name: string, make: () => T, create: Create<T, A>): SubModel<T, A>;
|
||||
function defineView<T>(name: string, make: () => T): SubModel<T, Setter<T>>;
|
||||
function defineView<T>(
|
||||
name: string,
|
||||
make: () => T,
|
||||
create?: Create<T, unknown>,
|
||||
): SubModel<T, unknown> {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: wraps into SubModel with erased action type
|
||||
return new SubModel<T, any>(name, make, create ?? defaultCreate, true);
|
||||
}
|
||||
|
||||
@@ -247,9 +272,12 @@ function memoize<T>(init: (use: Use, model: Model) => T) {
|
||||
const id = uuid();
|
||||
return {
|
||||
use(): T {
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: use() is called as a hook by consumers
|
||||
const { mem, model, use } = useContext(Context);
|
||||
const fn = mem[id] || (mem[id] = init(use, model));
|
||||
return fn as T;
|
||||
if (!mem[id]) {
|
||||
mem[id] = init(use, model);
|
||||
}
|
||||
return mem[id] as T;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -258,21 +286,29 @@ function compute<T>(calc: (use: UseV) => T) {
|
||||
const id = uuid();
|
||||
return {
|
||||
use(): T {
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: use() is called as a hook by consumers
|
||||
const { mem, query } = useContext(Context);
|
||||
let state: ReturnType<typeof generate<T>> = mem[id];
|
||||
if (state) return state.use();
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: deps collect heterogeneous SubModels
|
||||
const deps = new Set<SubModel<any, any>>();
|
||||
let usev = (m: SubModel<any, any>) => (deps.add(m), query(m).get());
|
||||
// biome-ignore lint/suspicious/noExplicitAny: useV erases action type
|
||||
let usev = (m: SubModel<any, any>) => {
|
||||
deps.add(m);
|
||||
return query(m).get();
|
||||
};
|
||||
mem[id] = state = generate<T>(calc(usev));
|
||||
if (deps.size) {
|
||||
usev = m => query(m).get();
|
||||
usev = (m) => query(m).get();
|
||||
const update = () => state.set(calc(usev));
|
||||
deps.forEach(m => query(m).listen(update));
|
||||
for (const m of deps) {
|
||||
query(m).listen(update);
|
||||
}
|
||||
}
|
||||
return state.use();
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const define = {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import {
|
||||
getSmoothStepPath,
|
||||
EdgeLabelRenderer,
|
||||
useReactFlow,
|
||||
type EdgeProps,
|
||||
type Edge,
|
||||
EdgeLabelRenderer,
|
||||
type EdgeProps,
|
||||
getSmoothStepPath,
|
||||
useReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import { useState, useRef, useEffect, useMemo, type ReactNode } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import type { ConditionalEdge as ConditionalEdgeType } from "../type.ts";
|
||||
import { useModel } from "../context.tsx";
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
import { useModel } from "../context.tsx";
|
||||
import type { ConditionalEdge as ConditionalEdgeType } from "../type.ts";
|
||||
|
||||
const SOURCE_COLOR = "#10b981";
|
||||
const TARGET_COLOR = "#3b82f6";
|
||||
@@ -38,7 +38,7 @@ function GradientPath({
|
||||
const gradientId = `gradient-${id}`;
|
||||
const showLack = hasCondition === false;
|
||||
const strokeStyle = selected
|
||||
? { stroke: '#f59e0b', strokeWidth: 2 }
|
||||
? { stroke: "#f59e0b", strokeWidth: 2 }
|
||||
: { stroke: `url(#${gradientId})`, strokeWidth: 1.5 };
|
||||
|
||||
return (
|
||||
@@ -63,13 +63,7 @@ function GradientPath({
|
||||
strokeWidth={20}
|
||||
className="react-flow__edge-interaction"
|
||||
/>
|
||||
<path
|
||||
id={id}
|
||||
d={path}
|
||||
fill="none"
|
||||
className="react-flow__edge-path"
|
||||
style={strokeStyle}
|
||||
/>
|
||||
<path id={id} d={path} fill="none" className="react-flow__edge-path" style={strokeStyle} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -143,13 +137,12 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: click handler on badge label */}
|
||||
<div onClick={handleBadgeClick} onKeyDown={undefined} className="cursor-pointer">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block px-1 bg-white rounded text-[10px]",
|
||||
condition
|
||||
? "border border-gray-300 text-black"
|
||||
: "border border-dashed text-red-500",
|
||||
condition ? "border border-gray-300 text-black" : "border border-dashed text-red-500",
|
||||
)}
|
||||
style={condition ? undefined : { borderColor: LACK_COLOR }}
|
||||
>
|
||||
@@ -166,7 +159,6 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -183,7 +175,7 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
||||
}
|
||||
|
||||
export function isElseEdge(edgeId: string, source: string, allEdges: Edge[]): boolean {
|
||||
const siblings = allEdges.filter(e => e.source === source && e.type === 'conditional');
|
||||
const siblings = allEdges.filter((e) => e.source === source && e.type === "conditional");
|
||||
return siblings.length >= 2 && siblings[0].id === edgeId;
|
||||
}
|
||||
|
||||
@@ -200,7 +192,13 @@ export function ConditionalEdge({
|
||||
data,
|
||||
}: EdgeProps<ConditionalEdgeType>): ReactNode {
|
||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||
sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, borderRadius: RADIUS,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
borderRadius: RADIUS,
|
||||
});
|
||||
const flow = useReactFlow();
|
||||
const model = useModel();
|
||||
@@ -224,14 +222,20 @@ export function ConditionalEdge({
|
||||
sourceY={sourceY}
|
||||
targetX={targetX}
|
||||
targetY={targetY}
|
||||
hasCondition={isElse ? null : (condition ? true : false)}
|
||||
hasCondition={isElse ? null : !!condition}
|
||||
selected={!!selected}
|
||||
/>
|
||||
<EdgeLabelRenderer>
|
||||
{isElse
|
||||
? <ElseBadge labelX={labelX} labelY={labelY} />
|
||||
: <ConditionLabel condition={condition} labelX={labelX} labelY={labelY} onSave={handleSave} />
|
||||
}
|
||||
{isElse ? (
|
||||
<ElseBadge labelX={labelX} labelY={labelY} />
|
||||
) : (
|
||||
<ConditionLabel
|
||||
condition={condition}
|
||||
labelX={labelX}
|
||||
labelY={labelY}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
</EdgeLabelRenderer>
|
||||
</>
|
||||
);
|
||||
@@ -248,7 +252,13 @@ export function GradientEdge({
|
||||
selected,
|
||||
}: EdgeProps<Edge>): ReactNode {
|
||||
const [edgePath] = getSmoothStepPath({
|
||||
sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, borderRadius: RADIUS,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
borderRadius: RADIUS,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ConditionalEdge, GradientEdge } from './conditional';
|
||||
import { ConditionalEdge, GradientEdge } from "./conditional";
|
||||
|
||||
export const edgeTypes = {
|
||||
conditional: ConditionalEdge,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { memo, createElement, useLayoutEffect, useEffect, createContext, useContext } from 'react';
|
||||
import { ReactFlow, ReactFlowProvider, Controls, Background, type Edge } from '@xyflow/react';
|
||||
// @ts-ignore
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { nodesModel, edgesModel, handlers, injection } from './model';
|
||||
import { ModelProvider, RegisterFlowToContext } from './context';
|
||||
import { nodeTypes } from './nodes';
|
||||
import { edgeTypes } from './edges';
|
||||
import { Dialogs, TopCenterPanel } from './panel';
|
||||
import type { AnyWorkNode } from './type';
|
||||
import { FlowModel, InternalField } from './injection';
|
||||
import { Background, Controls, type Edge, ReactFlow, ReactFlowProvider } from "@xyflow/react";
|
||||
import { createContext, createElement, memo, useContext, useEffect, useLayoutEffect } from "react";
|
||||
// @ts-expect-error
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { ModelProvider, RegisterFlowToContext } from "./context";
|
||||
import { edgeTypes } from "./edges";
|
||||
import { FlowModel, InternalField } from "./injection";
|
||||
import { edgesModel, handlers, injection, nodesModel } from "./model";
|
||||
import { nodeTypes } from "./nodes";
|
||||
import { Dialogs, TopCenterPanel } from "./panel";
|
||||
import type { AnyWorkNode } from "./type";
|
||||
|
||||
export * from './trans/type';
|
||||
export * from "./trans/type";
|
||||
|
||||
const proOptions = { hideAttribution: true };
|
||||
|
||||
@@ -20,11 +20,13 @@ export const useReadonly = () => useContext(ReadonlyContext);
|
||||
function Flow() {
|
||||
const [nodes, { onNodesChange }] = nodesModel.use();
|
||||
const [edges, { onEdgesChange, onConnect }] = edgesModel.use();
|
||||
const { onNodeDragStart, onNodeDragStop, onConnectEnd, onBeforeDelete, onDelete, handleKeyDown } = handlers.use();
|
||||
const { onNodeDragStart, onNodeDragStop, onConnectEnd, onBeforeDelete, onDelete, handleKeyDown } =
|
||||
handlers.use();
|
||||
const readonly = useReadonly();
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%' }} tabIndex={0} onKeyDown={readonly ? undefined : handleKeyDown}>
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: keyboard handler for flow shortcuts
|
||||
<div style={{ height: "100%" }} onKeyDown={readonly ? undefined : handleKeyDown}>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow<AnyWorkNode, Edge>
|
||||
nodes={nodes}
|
||||
@@ -70,11 +72,11 @@ function Connect({ model }: { model: FlowModel }) {
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return inject(instance);
|
||||
}, [instance]);
|
||||
}, [instance, inject]);
|
||||
|
||||
useEffect(() => {
|
||||
return instance.on('load', loadSteps);
|
||||
}, [instance]);
|
||||
return instance.on("load", loadSteps);
|
||||
}, [instance, loadSteps]);
|
||||
|
||||
return <MemoFlow />;
|
||||
}
|
||||
@@ -83,8 +85,6 @@ export { FlowModel };
|
||||
// biome-ignore lint/style/noDefaultExport: FlowEditor is the main public component
|
||||
export default ({ model, readonly = false }: Props) => (
|
||||
<ReadonlyContext.Provider value={readonly}>
|
||||
<ModelProvider>
|
||||
{createElement(Connect, { model })}
|
||||
</ModelProvider>
|
||||
<ModelProvider>{createElement(Connect, { model })}</ModelProvider>
|
||||
</ReadonlyContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { WorkFlowSteps } from "./trans";
|
||||
import { Eventer } from './utils/eventer';
|
||||
import type { WorkFlowSteps } from "./trans";
|
||||
import { Eventer } from "./utils/eventer";
|
||||
|
||||
interface PublicEvents {
|
||||
save: WorkFlowSteps;
|
||||
@@ -9,19 +9,21 @@ interface PrivateEvents {
|
||||
load: WorkFlowSteps;
|
||||
}
|
||||
|
||||
export const InternalField = Symbol('InternalField');
|
||||
export const InternalField = Symbol("InternalField");
|
||||
|
||||
export class Injection extends Eventer<PrivateEvents> {
|
||||
constructor(
|
||||
public readonly emitPublic: Eventer<PublicEvents>['emit'],
|
||||
private inital_steps?: WorkFlowSteps,
|
||||
) {
|
||||
public readonly emitPublic: Eventer<PublicEvents>["emit"];
|
||||
private inital_steps: WorkFlowSteps | undefined;
|
||||
|
||||
constructor(emitPublic: Eventer<PublicEvents>["emit"], inital_steps?: WorkFlowSteps) {
|
||||
super();
|
||||
this.emitPublic = emitPublic;
|
||||
this.inital_steps = inital_steps;
|
||||
}
|
||||
|
||||
public on: Eventer<PrivateEvents>['on'] = (type, lisenter) => {
|
||||
const off = super.on(type, lisenter);
|
||||
if (type === 'load' && this.inital_steps) {
|
||||
public on: Eventer<PrivateEvents>["on"] = (type, lisenter) => {
|
||||
const off = super.on(type, lisenter);
|
||||
if (type === "load" && this.inital_steps) {
|
||||
lisenter(this.inital_steps);
|
||||
this.inital_steps = undefined;
|
||||
}
|
||||
@@ -37,13 +39,10 @@ export class FlowModel {
|
||||
public readonly [InternalField]: Injection;
|
||||
|
||||
constructor(inital_steps?: WorkFlowSteps) {
|
||||
this[InternalField] = new Injection(
|
||||
this.eventer.emit.bind(this.eventer),
|
||||
inital_steps,
|
||||
);
|
||||
this[InternalField] = new Injection(this.eventer.emit.bind(this.eventer), inital_steps);
|
||||
}
|
||||
|
||||
public load(steps: WorkFlowSteps) {
|
||||
this[InternalField].emit('load', steps);
|
||||
this[InternalField].emit("load", steps);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Node, Edge } from '@xyflow/react';
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
|
||||
const DEFAULT_NODE_WIDTH = 120;
|
||||
const DEFAULT_NODE_HEIGHT = 50;
|
||||
@@ -34,8 +34,8 @@ function buildGraph(nodes: Node[], edges: Edge[]) {
|
||||
// 构建图
|
||||
for (const edge of edges) {
|
||||
if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) {
|
||||
outgoing.get(edge.source)!.push(edge.target);
|
||||
incoming.get(edge.target)!.push(edge.source);
|
||||
outgoing.get(edge.source)?.push(edge.target);
|
||||
incoming.get(edge.target)?.push(edge.source);
|
||||
inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,65 @@ function buildGraph(nodes: Node[], edges: Edge[]) {
|
||||
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 层
|
||||
@@ -52,62 +111,15 @@ function buildGraph(nodes: Node[], edges: Edge[]) {
|
||||
function assignLayers(nodes: Node[], edges: Edge[]): Map<string, number> {
|
||||
const { outgoing, inDegree } = buildGraph(nodes, edges);
|
||||
const layers = new Map<string, number>();
|
||||
const queue: string[] = [];
|
||||
|
||||
// 1. start 节点固定在第 0 层
|
||||
layers.set('start', 0);
|
||||
queue.push('start');
|
||||
layers.set("start", 0);
|
||||
bfsLayers(outgoing, inDegree, layers);
|
||||
|
||||
// 2. BFS 分层(排除 end 节点,稍后单独处理)
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
const currentLayer = layers.get(current)!;
|
||||
const afterBfsMax = maxLayerExcludingEnd(layers);
|
||||
placeIsolatedNodes(nodes, layers, afterBfsMax);
|
||||
|
||||
for (const target of outgoing.get(current) ?? []) {
|
||||
// 跳过 end 节点,稍后处理
|
||||
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);
|
||||
const finalMax = maxLayerExcludingEnd(layers);
|
||||
layers.set("end", finalMax + 1);
|
||||
|
||||
return layers;
|
||||
}
|
||||
@@ -123,7 +135,7 @@ function groupByLayer<N extends Node>(nodes: N[], layers: Map<string, number>):
|
||||
if (!groups.has(layer)) {
|
||||
groups.set(layer, []);
|
||||
}
|
||||
groups.get(layer)!.push(node);
|
||||
groups.get(layer)?.push(node);
|
||||
}
|
||||
|
||||
return groups;
|
||||
@@ -152,7 +164,7 @@ function calculateLayerWidths(layerGroups: Map<number, Node[]>): Map<number, num
|
||||
*/
|
||||
function calculateLayerXPositions(
|
||||
layerWidths: Map<number, number>,
|
||||
maxLayer: number
|
||||
maxLayer: number,
|
||||
): Map<number, number> {
|
||||
const xPositions = new Map<number, number>();
|
||||
let currentX = 0;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { Edge } from '@xyflow/react';
|
||||
import { define } from '../context';
|
||||
import { nodesModel } from './nodes';
|
||||
import { edgesModel } from './edges';
|
||||
import type { RoleNodeData, AnyWorkNode } from '../type';
|
||||
import type { Edge } from "@xyflow/react";
|
||||
import { define } from "../context";
|
||||
import type { AnyWorkNode, RoleNodeData } from "../type";
|
||||
import { edgesModel } from "./edges";
|
||||
import { nodesModel } from "./nodes";
|
||||
|
||||
type ConnectHandle = {
|
||||
id?: string | null;
|
||||
nodeId: string;
|
||||
type: 'source' | 'target';
|
||||
type: "source" | "target";
|
||||
};
|
||||
|
||||
export type AddNodeState = {
|
||||
@@ -21,10 +21,10 @@ type CommitParams = {
|
||||
};
|
||||
|
||||
function addNodeView() {
|
||||
return null as (AddNodeState | null);
|
||||
return null as AddNodeState | null;
|
||||
}
|
||||
|
||||
export const addNodeViewModel = define.view('addNodeView', addNodeView, (set, get, model) => {
|
||||
export const addNodeViewModel = define.view("addNodeView", addNodeView, (set, get, model) => {
|
||||
function start(state: AddNodeState) {
|
||||
set(state);
|
||||
}
|
||||
@@ -42,12 +42,19 @@ export const addNodeViewModel = define.view('addNodeView', addNodeView, (set, ge
|
||||
const { data } = params;
|
||||
|
||||
const id = `n${Date.now()}`;
|
||||
const node = { id, data, position, type: 'role' as const, origin: [0.0, 0.5] as [number, number] };
|
||||
const node = {
|
||||
id,
|
||||
data,
|
||||
position,
|
||||
type: "role" as const,
|
||||
origin: [0.0, 0.5] as [number, number],
|
||||
};
|
||||
|
||||
const [fnid, fhid] = [fromNode.id, fromHandle.id];
|
||||
const newEdge: Edge = fromHandle.type === 'source'
|
||||
? { id: `e${fnid}-${id}`, source: fnid, target: id, sourceHandle: fhid, animated: true }
|
||||
: { id: `e${id}-${fnid}`, source: id, target: fnid, targetHandle: fhid, animated: true };
|
||||
const newEdge: Edge =
|
||||
fromHandle.type === "source"
|
||||
? { id: `e${fnid}-${id}`, source: fnid, target: id, sourceHandle: fhid, animated: true }
|
||||
: { id: `e${id}-${fnid}`, source: id, target: fnid, targetHandle: fhid, animated: true };
|
||||
|
||||
model.startTransaction();
|
||||
model.use(nodesModel)[1].set((nds) => nds.concat(node));
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import {
|
||||
applyEdgeChanges,
|
||||
type Edge,
|
||||
type EdgeChange,
|
||||
type Connection,
|
||||
} from '@xyflow/react';
|
||||
import { define } from '../context';
|
||||
import { applyEdgeChanges, type Connection, type Edge, type EdgeChange } from "@xyflow/react";
|
||||
import { define } from "../context";
|
||||
|
||||
function makeEdges(): Edge[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
function isInputHandle(handle: string | null | undefined): boolean {
|
||||
return handle === 'input' || handle === 'input-top' || handle === 'input-bottom';
|
||||
return handle === "input" || handle === "input-top" || handle === "input-bottom";
|
||||
}
|
||||
|
||||
function isOutputHandle(handle: string | null | undefined): boolean {
|
||||
return handle === 'output' || handle === 'output-top' || handle === 'output-bottom';
|
||||
return handle === "output" || handle === "output-top" || handle === "output-bottom";
|
||||
}
|
||||
|
||||
function normalizeConnection(params: Edge | Connection): Edge | Connection {
|
||||
@@ -33,10 +28,10 @@ function normalizeConnection(params: Edge | Connection): Edge | Connection {
|
||||
|
||||
let edgeCounter = 0;
|
||||
|
||||
export const edgesModel = define.model('edges', makeEdges, (set, get, model) => {
|
||||
export const edgesModel = define.model("edges", makeEdges, (set, get, model) => {
|
||||
function onEdgesChange(changes: EdgeChange[]) {
|
||||
const whites = new Set(['add', 'replace']);
|
||||
if (changes.some(c => whites.has(c.type))) {
|
||||
const whites = new Set(["add", "replace"]);
|
||||
if (changes.some((c) => whites.has(c.type))) {
|
||||
model.startTransaction();
|
||||
set((eds) => applyEdgeChanges(changes, eds));
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
@@ -54,7 +49,7 @@ export const edgesModel = define.model('edges', makeEdges, (set, get, model) =>
|
||||
|
||||
const currentEdges = get();
|
||||
const duplicate = currentEdges.some(
|
||||
e => e.source === normalized.source && e.target === normalized.target,
|
||||
(e) => e.source === normalized.source && e.target === normalized.target,
|
||||
);
|
||||
if (duplicate) return;
|
||||
|
||||
@@ -67,15 +62,15 @@ export const edgesModel = define.model('edges', makeEdges, (set, get, model) =>
|
||||
animated: true,
|
||||
} as Edge;
|
||||
|
||||
const existingFromSource = currentEdges.filter(e => e.source === normalized.source);
|
||||
const existingFromSource = currentEdges.filter((e) => e.source === normalized.source);
|
||||
|
||||
if (existingFromSource.length > 0) {
|
||||
edge.type = 'conditional';
|
||||
edge.data = { condition: '' };
|
||||
edge.type = "conditional";
|
||||
edge.data = { condition: "" };
|
||||
|
||||
const promoted = currentEdges.map(e => {
|
||||
if (e.source === normalized.source && e.type !== 'conditional') {
|
||||
return { ...e, type: 'conditional' as const, data: { condition: '' } };
|
||||
const promoted = currentEdges.map((e) => {
|
||||
if (e.source === normalized.source && e.type !== "conditional") {
|
||||
return { ...e, type: "conditional" as const, data: { condition: "" } };
|
||||
}
|
||||
return e;
|
||||
});
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { define } from '../context';
|
||||
import { nodesModel } from './nodes';
|
||||
import type { RoleNodeData, WorkNode } from '../type';
|
||||
import { define } from "../context";
|
||||
import type { RoleNodeData, WorkNode } from "../type";
|
||||
import { nodesModel } from "./nodes";
|
||||
|
||||
export type EditNodeState = {
|
||||
node: WorkNode<'role'>;
|
||||
node: WorkNode<"role">;
|
||||
};
|
||||
|
||||
function editNodeView() {
|
||||
return null as (EditNodeState | null);
|
||||
return null as EditNodeState | null;
|
||||
}
|
||||
|
||||
export const editNodeViewModel = define.view('editNodeView', editNodeView, (set, get, model) => {
|
||||
export const editNodeViewModel = define.view("editNodeView", editNodeView, (set, get, model) => {
|
||||
function start(nodeId: string) {
|
||||
const [nodes] = model.use(nodesModel);
|
||||
const node = nodes.find(n => n.id === nodeId);
|
||||
if (!node || node.type !== 'role') return;
|
||||
set({ node: node as WorkNode<'role'> });
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
if (!node || node.type !== "role") return;
|
||||
set({ node: node as WorkNode<"role"> });
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
@@ -31,6 +31,7 @@ export const editNodeViewModel = define.view('editNodeView', editNodeView, (set,
|
||||
|
||||
model.startTransaction();
|
||||
editNode(state.node.id, (node) => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: node data type varies by node kind
|
||||
node.data = data as any;
|
||||
});
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user