Compare commits

...

65 Commits

Author SHA1 Message Date
xiaoju f45563ee31 refactor(cli-workflow): reduce cmdStepRead cognitive complexity
Extract four helper functions from cmdStepRead to reduce cognitive
complexity from 27 to ≤15:
- loadStepDetail: Load and validate step detail node
- loadTurnData: Load all turn nodes and extract content
- selectTurnsForQuota: Select turns within quota (≥1 always shown)
- formatStepMarkdown: Assemble final markdown output

All 6 existing tests pass. Zero Biome warnings. CLAUDE.md compliant.

Fixes #487
2026-05-25 02:17:55 +00:00
xiaoju 2b8cd99100 fix(agent-claude-code): use buildContinuationPrompt for step context
Replace custom buildHistorySummary with shared buildContinuationPrompt
from workflow-agent-kit. This aligns claude-code agent with hermes agent:

- First visit: includes step content (within 32k quota)
- Re-entry: shows only steps since last visit (meta only, session has context)

Previously developer could not see reviewer's detailed feedback on
re-entry, only {"approved":false}. Now gets full review text.

Fixes #486
2026-05-25 02:01:57 +00:00
xiaoju 1ca13e02b2 Merge pull request 'feat(cli): implement step read command' (#485) from fix/484-step-read-command into main 2026-05-25 01:43:12 +00:00
xiaoju 3146832d1b fix(cli-workflow): complete step read command implementation
Implements the `uwf step read` command to render a single step's turns
as human-readable markdown with quota enforcement.

Changes:
- Implement cmdStepRead() in step.ts with quota enforcement
  - Renders step metadata (hash, role, agent)
  - Loads and formats turns from detail node
  - Enforces quota by selecting most recent turns
  - Always shows at least one turn even if it exceeds quota
  - Gracefully handles steps with no detail or no turns
- Register `step read` command in cli.ts with --quota flag (default 4000)
- Add comprehensive test suite in step-read.test.ts (6 tests covering
  basic functionality, quota enforcement, edge cases, and special chars)
- Update README.md CLI Reference table to include `step read`
- Update package-level README.md with command documentation and example

Closes #484

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-25 01:36:25 +00:00
xiaoju 64f929c10d Merge pull request 'fix(cli-workflow): fix thread read --quota flag implementation' (#483) from fix/480-thread-read-quota into main 2026-05-25 01:18:21 +00:00
xiaoju 1ec32ae0fd Merge pull request 'fix: cas has now returns exit 1 for non-existent hashes' (#482) from fix/481-cas-has-exit-code into main 2026-05-25 01:18:07 +00:00
xiaoju f851a087f2 fix(cli-workflow): fix thread read --quota flag implementation
Issue #480: The --quota flag on 'uwf thread read' was not properly
limiting output size due to an off-by-one error in selectByQuota().

Root cause:
- Items were added to selected array BEFORE checking if they would
  exceed the quota
- This meant the last item that exceeded quota was still included
- Prompt deduplication tracking was mutated during quota calculation,
  causing prompts to not render in final output

Fix:
- Check quota BEFORE adding items to selected array
- Always include at least one step even if it exceeds quota
- Calculate step lengths using actual rendering format
- Account for start section and separators in quota calculation
- Use temporary Set during length calculation to avoid mutating
  the prompt deduplication tracking

Tests:
- Added comprehensive test suite (thread-read-quota.test.ts)
- Covers quota enforcement, boundary conditions, edge cases
- Tests interaction with --before and --start flags
- All tests pass

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-25 00:58:30 +00:00
xiaoju 984e6ae56d fix: cas has now returns exit 1 for non-existent hashes
Changed the exit behavior of 'uwf cas has' to return exit code 1 when
a hash doesn't exist, while preserving the JSON output {exists:false}.
This enables proper use in shell conditionals like 'if uwf cas has $HASH'.

Fixes #481
2026-05-25 00:47:39 +00:00
xiaoju 92f3b36b10 chore: add uwf script for local dev testing
`bun run uwf -- <args>` runs the local version of uwf CLI,
useful in worktrees to test local changes vs the global install.
2026-05-25 00:03:10 +00:00
xiaoju a4677f8adb docs: sync README files with recent changes 2026-05-24 17:04:09 +00:00
xiaoju 9ab6291a41 fix(workflow): add --repo flag to tea pr create in worktree dirs
Fixes #474
2026-05-24 16:56:19 +00:00
xiaoju 50a4db72b1 fix(workflow): add check step to developer, clarify reviewer hard/soft checks
Developer procedure now requires running lint/build checks before committing.
Reviewer procedure clarified: hard checks (build/lint) must pass, style-only
suggestions should not block approval.

Fixes #477
2026-05-24 16:43:07 +00:00
xiaoju dfdf0ac073 fix(cli-workflow): resolve step/thread commands on completed threads
Fixed issue #469 where `uwf step list`, `uwf step show`, and `uwf thread read`
failed with "thread not active" error when called on completed threads.

The root cause was that resolveHeadHash() in shared.ts only checked threads.yaml
(active threads index) but never fell back to history.jsonl (completed threads log).

Changes:
- Updated resolveHeadHash() in shared.ts to check history.jsonl as fallback
- Changed error message from "thread not active" to "thread not found"
- Added comprehensive test coverage:
  - Unit tests for resolveHeadHash() with active/completed/missing threads
  - Integration tests for cmdStepList() with completed threads
  - Integration tests for cmdStepShow() with completed threads
  - Regression tests for cmdThreadRead() with completed threads

All commands now work identically for active and completed threads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 16:18:20 +00:00
xiaoju c2c849df7e fix(agent-kit): provide full thread context to first-time participating roles
When a role participates for the first time (e.g. committer), it previously
only received the system prompt + last step output, missing the full thread
history. This caused hallucination as the role had to guess what happened.

Changes:
- build-continuation-prompt.ts: detect first-time roles and include all
  steps' meta + content for last 2-3 steps (within quota)
- context.ts: add isFirstVisit detection helper
- types.ts: add isFirstVisit field to AgentContext
- hermes.ts: pass isFirstVisit through to prompt builder

Fixes #473
2026-05-24 15:56:39 +00:00
xiaoju 39f6ae692b feat(cli): add filtering and pagination to thread list command
Implements enhanced filtering and pagination for the `uwf thread list` command
to support workflows with large numbers of threads.

Changes:
- Add --page, --page-size parameters for pagination (default: page 1, size 20)
- Add --since, --until time filters supporting multiple formats (ISO8601, relative like "2h", "1d")
- Add --workflow filter to show threads for specific workflow
- Add --sort parameter (newest-first, oldest-first, alphabetical)
- Add pagination metadata in JSON output (page, pageSize, totalThreads, totalPages, hasMore)
- Implement parseRelativeTime() for human-friendly time expressions (1h, 30m, 2d, 1w)
- Add comprehensive unit tests for filters, pagination, and time parsing
- Update CLI help text with new parameters and examples

Fixes #471
2026-05-24 14:44:30 +00:00
xiaomo eb027e70f4 fix: include step content in continuation prompt (closes #466)
- Add `content: string | null` to RoleStep type
- Resolve contentHash → text for the last step when building ThreadContext
- Update buildAgentPrompt to include <output> tag with step content
- Add 16k content quota with truncation
- Update tests
2026-05-24 13:41:00 +00:00
xiaomo 8fbbbce07e Merge pull request 'chore: cleanup dead code and update CLI docs' (#468) from chore/cleanup-cli-docs into main 2026-05-24 11:42:36 +00:00
xiaoju f115718564 chore: cleanup dead code and update CLI docs
- Remove cmdThreadRunning dead code (CLI uses --status running now)
- Remove step read from README (command not registered)
- Update cli-reference.ts to reflect new four-layer commands

Refs #463
2026-05-24 11:41:02 +00:00
xiaomo 5c0eabda8e Merge pull request 'feat: restructure CLI commands (workflow/thread/step/turn)' (#467) from fix/463-http-methods into main 2026-05-24 11:37:50 +00:00
xiaoju 669af841e1 refactor: address review feedback for CLI restructure
- Extract shared module (shared.ts) — walkChain, expandDeep, etc. deduplicated
- Hide step read command (half-baked, not ready for users)
- Remove cmdThreadKill dead code
- Revert unrelated protocol type change
- Revert unrelated package.json change
- Fix unused imports (biome)

Refs #463
2026-05-24 11:32:47 +00:00
xiaoju 650313b1c2 feat(step): expand detail CAS refs by default in step list
Previously step list showed raw CAS refs for detail fields.
Now detail is recursively expanded (like output already was),
since every turn is individually hashed and walkable.

Refs #463
2026-05-24 11:12:22 +00:00
xiaoju c40007eeaf fix(agent-claude-code): add missing workflow-util dependency
The claude-code agent imports createLogger from @uncaged/workflow-util
but was missing the dependency declaration, causing test failures.
2026-05-24 11:04:02 +00:00
xiaoju 1f13b1e79c fix(cli): resolve lint errors and unused imports (#463)
Fix all lint errors flagged by biome check to ensure clean codebase.

## Changes

### Removed Unused Imports
- `packages/cli-workflow/src/commands/thread.ts`:
  - Removed `StartEntry` (moved to step.ts)
  - Removed `StepEntry` (moved to step.ts)
  - Removed `ThreadForkOutput` (moved to step.ts)
  - Removed `ThreadStepsOutput` (moved to step.ts)

- `packages/cli-workflow/src/cli.ts`:
  - Removed unused `yamlStringify` import from yaml package

### Fixed Unused Parameter
- `packages/cli-workflow/src/commands/step.ts`:
  - Prefixed unused `before` parameter with underscore in `cmdStepRead`
  - Parameter is part of the function signature for future use (awaiting #462)

### Fixed Import Order
- `packages/cli-workflow/src/__tests__/thread.test.ts`:
  - Reordered imports to follow biome's organization rules
  - Moved cmdStepShow import before cmdThreadRead imports

## Test Results
-  `bun run check` passes (typecheck + lint + log tags)
-  All 124 tests passing
-  Build completes successfully

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 10:50:49 +00:00
xiaoju 031c3aa632 docs(cli): add deprecation handlers and update documentation (#463)
Complete the CLI refactoring with deprecation error handlers, updated
help text, and comprehensive migration guide.

## Changes

### Deprecation Handlers
Add error handlers for all removed commands with helpful migration messages:
- `workflow put` → suggests `workflow add`
- `thread step` → suggests `thread exec`
- `thread steps` → suggests `step list`
- `thread step-details` → suggests `step show`
- `thread fork` → suggests `step fork`
- `thread kill` → suggests `thread stop` or `thread cancel`
- `thread running` → suggests `thread list --status running`

Error messages follow the format:
```
Error: Command 'X' has been removed.
Use 'Y' instead.

For more information, see: uwf help Y
```

### Help Documentation
Updated CLI help text to explain four-layer architecture:
- Main help shows architecture diagram with Chinese labels
- Command group descriptions reference layers:
  - `workflow` → "Workflow definitions (layer 1: templates)"
  - `thread` → "Thread execution (layer 2: instances)"
  - `step` → "Step results (layer 3: single cycle)"
- Deprecated commands appear in help with [DEPRECATED] tag

### README Updates
Comprehensive documentation updates:
- Added "Four-Layer Architecture" section with diagram
- Updated all command tables with new command names
- Added complete migration guide with:
  - Renamed commands table
  - Merged commands table
  - Split commands table
  - Moved commands table
  - Example deprecation error output
- Updated "Internal Structure" to show new step.ts module

## Testing
-  All 124 tests pass
-  Build completes successfully
-  Deprecation handlers tested manually
-  Help output verified for main, thread, and step commands

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 10:46:31 +00:00
xiaoju 7b50969307 refactor(cli): reorganize CLI commands into four-layer model (#463)
Implement comprehensive CLI refactoring to clarify the four-layer model:
workflow → thread → step → turn

## Breaking Changes

### Renamed Commands
- `uwf workflow put` → `uwf workflow add`
- `uwf thread step` → `uwf thread exec`

### Removed Commands
- `uwf thread running` (merged into `thread list --status running`)
- `uwf thread kill` (split into `thread stop` and `thread cancel`)

### Moved Commands
- `uwf thread steps` → `uwf step list`
- `uwf thread step-details` → `uwf step show`
- `uwf thread fork` → `uwf step fork`

## New Commands

### Thread Commands
- `uwf thread list --status <idle|running|completed>` - Filter threads by status
- `uwf thread stop <thread-id>` - Stop background execution (keep thread active)
- `uwf thread cancel <thread-id>` - Cancel thread (stop + archive to history)

### Step Command Group (New)
- `uwf step list <thread-id>` - List all steps in a thread
- `uwf step show <step-hash>` - Show step details
- `uwf step read <step-hash> [--before N]` - Read step output as markdown
- `uwf step fork <step-hash>` - Fork thread from a step

## Implementation Details

### Files Modified
- `packages/cli-workflow/src/commands/workflow.ts` - Renamed cmdWorkflowPut → cmdWorkflowAdd
- `packages/cli-workflow/src/commands/thread.ts`:
  - Renamed cmdThreadStep → cmdThreadExec
  - Added cmdThreadStop and cmdThreadCancel (split from cmdThreadKill)
  - Updated cmdThreadList to support --status filter with idle/running/completed
  - Removed cmdThreadSteps, cmdThreadStepDetails, cmdThreadFork
- `packages/cli-workflow/src/commands/step.ts` - New module with:
  - cmdStepList (moved from cmdThreadSteps)
  - cmdStepShow (moved from cmdThreadStepDetails)
  - cmdStepFork (moved from cmdThreadFork)
  - cmdStepRead (new, stub implementation pending #462)
- `packages/cli-workflow/src/cli.ts` - Updated all CLI command registrations

### Tests Updated
- `packages/cli-workflow/src/__tests__/thread-step-count.test.ts` - Updated references from "thread step" to "thread exec"
- `packages/cli-workflow/src/__tests__/thread.test.ts` - Updated imports to use cmdStepShow from step.ts

## Test Results
All 124 tests pass in cli-workflow package.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 10:40:32 +00:00
xiaoju fc6072c28c Merge pull request 'feat: use git worktree for isolated development in solve-issue workflow' (#465) from fix/464-worktree-isolation into main 2026-05-24 10:27:51 +00:00
xiaoju b0e3f4a363 feat: use git worktree for isolated development in solve-issue workflow
All roles (developer, reviewer, tester, committer) now work in
~/repos/workflow-worktrees/fix/<issue>-<slug> instead of modifying
the main working directory. Prevents self-destructive edits.

Fixes #464
2026-05-24 10:22:25 +00:00
xiaoju 38112053a0 Merge pull request 'fix(agent-kit): separate session cache per agent' (#462) from fix/461-per-agent-session-cache into main 2026-05-24 09:19:50 +00:00
xiaoju 1d174ee5c9 fix(agent-kit): separate session cache per agent
Each agent now maintains its own session cache file instead of sharing
a single agent-sessions.json. This prevents session ID conflicts when
multiple agents operate on the same thread+role pair.

Changes:
- getCachePath() now takes agentName parameter
- getCachedSessionId/setCachedSessionId require agentName as first param
- Cache files named <agent>-sessions.json (e.g., hermes-sessions.json)
- Agent wrappers inject their agent name into cache calls
- Add comprehensive tests for session cache isolation
- Handle malformed JSON gracefully (treat as empty cache)

Fixes #461
2026-05-24 09:16:06 +00:00
xiaoju 6e3b32ca34 Merge pull request 'fix(cli): replace markdown headings with XML tags in thread read output' (#460) from fix/459-xml-tag-isolation into main 2026-05-24 08:44:47 +00:00
xiaoju 932bbe5c41 fix(cli): replace markdown headings with XML tags in thread read output
Changed uwf thread read to wrap role prompts and agent outputs in XML tags
(<prompt> and <output>) instead of markdown headings (### Prompt, ### Content).
This prevents Claude Code from treating step outputs as structural headings.

- Updated formatStepPrompt to use <prompt>...</prompt> tags
- Updated formatStepContent to use <output>...</output> tags
- Added comprehensive test suite in thread-read-xml-tags.test.ts
- Updated existing tests to verify XML tag behavior

Fixes #459

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 08:04:34 +00:00
xiaomo 9440b9af82 Merge pull request 'chore: fix biome noExcessiveCognitiveComplexity warnings' (#458) from fix/444-biome-complexity-warnings into main 2026-05-24 07:30:41 +00:00
xiaoju f96d6eb7c4 refactor(agent-builtin): reduce cognitive complexity in loop.ts
Refactored runBuiltinLoop function to reduce cognitive complexity from 30 to below 15 by extracting helper functions:

- shouldInjectDeadlineWarning: checks if deadline warning should be shown
- shouldProcessToolCalls: determines if tool calls should be processed
- extractFinalText: extracts last assistant message content
- injectDeadlineWarning: injects deadline warning message
- handleTextOnlyTurn: handles text-only turn logic
- handleToolCallTurn: handles tool call turn logic
- processLoopIteration: processes a single loop iteration

Added 24 new unit tests for the extracted helper functions, bringing total test count to 41 (all passing). All existing behavior is preserved.

Fixes #444

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 05:53:55 +00:00
xiaomo 95102941f1 Merge pull request 'feat(cli): thread step --background + thread running' (#457) from fix/456-thread-step-background into main 2026-05-24 05:33:56 +00:00
xiaoju 521d908719 feat(cli): add background thread execution and running threads query
This commit implements issue #456, adding two related capabilities to the uwf CLI:

1. **Background execution mode** for `uwf thread step` (via `--background` flag)
   - Spawns agent execution in a detached background process
   - Returns immediately with thread ID and background status
   - Maintains marker files to track running processes
   - Supports `--count` option to run multiple steps in background
   - Prevents concurrent execution of the same thread

2. **Running threads query** command (`uwf thread running`)
   - Lists all threads currently executing in background
   - Returns thread ID, workflow, current role, PID, and start time
   - Automatically filters out stale markers (dead processes)
   - Empty list when no threads are running

**Key changes:**

- **workflow-protocol**: Added `RunningThreadItem`, `RunningThreadsOutput` types
  Updated `StepOutput` to include `background: boolean | null` field

- **cli-workflow/background**: New module for process management
  - Marker file creation/deletion (atomic operations)
  - PID liveness checking
  - Stale marker cleanup
  - Running threads query

- **cli-workflow/commands/thread**:
  - Updated `cmdThreadStep` to support `--background` and `--_background-worker` flags
  - Added `cmdThreadStepBackground` for spawning detached processes
  - Added `cmdThreadRunning` to list running threads
  - Updated `cmdThreadKill` to terminate background processes

- **cli-workflow/cli**: Added CLI routing for new commands and flags

**Integration:**
- `uwf thread kill` now terminates background processes before archiving
- Foreground execution checks for existing background process and fails if found
- Background worker creates/cleans up marker files automatically
- Marker files stored in `~/.uncaged/workflow/running/*.json`

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-24 05:28:29 +00:00
xiaoju 02a2c00175 refactor: replace UWF_EDGE_PROMPT env var with named CLI args
Agent adapters now use named parameters:
  uwf-<agent> --thread <id> --role <role> --prompt <text>

Instead of positional args + env var:
  UWF_EDGE_PROMPT=... uwf-<agent> <thread-id> <role>

Changes:
- workflow-agent-kit/src/run.ts: parseArgv uses named --thread/--role/--prompt
- workflow-agent-kit/src/context.ts: edgePrompt passed as parameter, not read from env
- cli-workflow/src/commands/thread.ts: spawnAgent passes named args

小橘 <xiaoju@shazhou.work>
2026-05-24 04:31:44 +00:00
xiaoju 8ca7708a12 fix: add cas_ref format to claude-code-detail turns schema
The turns array items in CLAUDE_CODE_DETAIL_SCHEMA were missing
format: 'cas_ref', so expandDeep in step-details couldn't resolve
turn hashes to their payloads. Hermes schema already had this.

小橘 <xiaoju@shazhou.work>
2026-05-24 04:17:29 +00:00
xiaomo 0fdc0fdec3 Merge pull request 'refactor(workflow-dashboard): reduce cyclomatic complexity in editor' (#455) from fix/449-reduce-dashboard-complexity into main 2026-05-24 03:44:08 +00:00
xiaomo d6eaf3fdc7 Merge pull request 'refactor: reduce cognitive complexity in session-detail and acp-client' (#454) from fix/448-reduce-complexity into main 2026-05-24 03:44:06 +00:00
xingyue 5dc2352ac5 fix(workflow-dashboard): replace optional properties with T | null in handlers.ts
Per CLAUDE.md convention, use `string | null` instead of `?:` in the
isFirstConditionalSibling helper function parameter types.

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
2026-05-24 00:52:54 +08:00
xingyue 39e2ab7f0d refactor(workflow-dashboard): reduce cyclomatic complexity in editor (#449)
- Extract helpers in assignLayers (bfsLayers, processTarget, placeIsolatedNodes, maxLayerExcludingEnd) to reduce complexity from 26 → ≤15
- Extract isProtectedNode and isFirstConditionalSibling helpers in onBeforeDelete (20 → ≤15)
- Extract handleEscape and handleUndoRedo in handleKeyDown (23 → ≤15)
- Extract buildNodeMap, sortTransitions, buildStepEdges, pushStepEdges, assignTargetHandles in transIn (33 → ≤15)
- Extract validateRoleNodeEdges and hasEmptyConditionOnIfEdge in validateRoleNodes (22 → ≤15)
- Remove unused state parameter from Form component in add-node.tsx
- Add vitest + 19 tests covering all refactored functions

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
2026-05-24 00:50:15 +08:00
xingyue 221919448e refactor: reduce cognitive complexity in session-detail and acp-client
Extract helper functions to bring parseClaudeCodeStreamOutput (37→≤15)
and handleSessionUpdate (24→≤15) within complexity limits. Add tests.

Fixes #448
2026-05-24 00:41:39 +08:00
xingyue 68b82c9574 style: use dot notation for process.env.CLAUDE_MODEL 2026-05-24 00:25:08 +08:00
xiaomo 335b8a4ae6 Merge pull request 'refactor(cli): reduce cognitive complexity in setup.ts' (#453) from fix/445-reduce-setup-complexity into main 2026-05-23 16:18:10 +00:00
xingyue bf31fa0d03 refactor(cli): reduce cognitive complexity in setup.ts
Extracts inline logic into focused helper functions to bring
each function under the complexity threshold.

Fixes #445
2026-05-24 00:14:15 +08:00
xiaomo c39f2f3e63 Merge pull request 'refactor(cli): reduce cognitive complexity in thread.ts' (#452) from fix/446-reduce-thread-complexity into main 2026-05-23 15:55:03 +00:00
xingyue 6481fc0cc5 refactor(cli): reduce cognitive complexity in thread.ts
Extract helper functions (resolveThreadId, getThreadHead, listThreadSteps,
displayStepDetails, displayThreadRead) to reduce nesting and improve
readability. Also adds test coverage for the refactored functions.

Fixes #446
2026-05-23 23:47:54 +08:00
xiaoju 3190e06ebe docs: add sync-readme rule for consistent README updates
小橘 🍊(NEKO Team)
2026-05-23 15:09:25 +00:00
xiaomo f8ae2fe25b Merge pull request 'docs: sync all README.md files with current codebase' (#451) from docs/sync-readme into main 2026-05-23 15:03:56 +00:00
xiaoju ffc31a8c19 docs: sync all README.md files with current codebase
- Root README: add all 9 packages to table, update architecture diagram,
  refresh CLI reference from uwf --help
- New READMEs for 8 packages (cli-workflow, workflow-protocol,
  workflow-moderator, workflow-agent-kit, workflow-agent-hermes,
  workflow-agent-builtin, workflow-agent-claude-code, workflow-dashboard)
- Updated workflow-util README to match current exports
- All API sections verified against src/index.ts exports

小橘 🍊(NEKO Team)
2026-05-23 15:00:05 +00:00
xingyue 48a274685b fix(builtin): nudge budget + deadline warning
- Nudge turns don't consume turn budget (up to MAX_NUDGES=3), prevents
  wasting agent work capacity on bookkeeping
- Inject deadline warning when 3 turns remain, telling agent to wrap up
- Agent can use status:failed to gracefully exit if it can't finish
2026-05-23 22:58:09 +08:00
xingyue 5b68359dfc fix #447: extract shouldNudge and export executeTurnTools from loop.ts, add tests 2026-05-23 22:45:09 +08:00
xingyue c2ddfb8558 fix(builtin): deadline warning + graceful exit on turn limit
- Inject user message when 3 turns remain, telling agent to wrap up
- Prompt tells agent to use status:failed if it can't finish in time
- Prevents wasting all turns without producing any frontmatter output
- Remove stale test file from dogfood agent run
2026-05-23 22:44:42 +08:00
xingyue 603018caf2 fix(builtin): force-strip tool_calls when noTools is set
copilot-api returns tool_calls even when tools field is omitted from
the request (infers from message history). Now the loop explicitly
nullifies tool_calls when noTools=true.
2026-05-23 22:35:20 +08:00
xiaomo aff0ee6fea Merge pull request 'fix(thread-read): remove ### Output section and deduplicate ### Prompt globally' (#442) from fix/440-thread-read-prompt-dedup into main 2026-05-23 14:15:40 +00:00
xiaomo d37fa1393a Merge pull request 'fix: preserve primary detail hash across frontmatter retries' (#443) from fix/439-detail-merge-and-acp into main 2026-05-23 14:14:53 +00:00
xiaoju 759c784267 fix: preserve primary detail hash across frontmatter retries
When the agent's first run output fails frontmatter extraction, the
retry loop (via options.continue) would replace agentResult entirely,
causing the 1-turn continuation detail to overwrite the original
multi-turn detail containing all tool-call history.

Now we capture primaryDetailHash from the first run and always use it
for the persisted StepNode, regardless of how many retries occur.

Fixes #439
2026-05-23 14:02:51 +00:00
xingyue 52ffc7dcc1 fix(thread-read): remove ### Output section and deduplicate ### Prompt globally 2026-05-23 22:01:24 +08:00
xingyue ac55a3e3d9 fix(builtin): nudge LLM when it stops tools without frontmatter
LLM sometimes emits plain text (e.g. 'Now I'll write the tests...')
without calling tools, which the loop treated as final output. Now
the loop detects this and injects a user message nudging the LLM
to either continue using tools or output frontmatter with ---.
2026-05-23 21:49:07 +08:00
xingyue edb979baa9 fix(builtin): disable tools during continue/retry to force frontmatter output
Agent was using all continue turns to keep calling tools instead of
outputting the required frontmatter. Now continue runs with noTools=true,
forcing LLM to emit text-only response.

Also supports null tools in chatCompletionWithTools to omit tools from
the API request entirely.
2026-05-23 21:40:30 +08:00
xingyue 3d1850ddbe fix(builtin): tell agent not to use uwf CLI to discover its task
Agent was wasting all 30 turns using uwf/tea CLI to explore threads
instead of reading the task from its own user message.
2026-05-23 21:30:59 +08:00
xingyue 3c1f4a6dfa fix(builtin): include cwd in system prompt
Agent was wasting turns exploring the filesystem because it didn't
know its working directory. Now the system prompt includes:
'Your working directory is: /path/to/cwd'
2026-05-23 21:27:24 +08:00
xiaomo f07a6daa30 Merge pull request 'fix(builtin): session lifecycle + frontmatter preamble stripping' (#441) from fix/builtin-session-lifecycle into main 2026-05-23 13:20:04 +00:00
xingyue 0eeb4a8ed8 fix(builtin): strip preamble before frontmatter + stronger prompt
- Add stripPreamble() to handle LLM output with text before ---
- Strengthen system prompt: CRITICAL instruction for --- at position 0
- Fixes frontmatter parsing failures on first output turn
2026-05-23 20:37:14 +08:00
xingyue a3fac708b6 fix(builtin-agent): don't delete session jsonl until process exits
Previously runBuiltinWithMessages deleted the session jsonl after each
run/continue call. This meant the createAgent retry mechanism (which
calls continue on frontmatter validation failure) would lose all
previous turn data — each continue started with an empty jsonl.

Now the session jsonl accumulates across run + continue calls, so the
final storeBuiltinDetail captures all turns. The jsonl file is left
behind for debugging; it's small and can be cleaned up on next startup.

Also add a workflow hint to the system prompt reminding the LLM to use
tools before outputting frontmatter, preventing premature text-only
responses on the first turn.
2026-05-23 20:32:38 +08:00
83 changed files with 9384 additions and 1258 deletions
+67
View File
@@ -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 -12
View File
@@ -38,19 +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 and rebase onto latest main:
`git checkout main && git pull && git checkout <branch> && git rebase main`
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
@@ -66,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
@@ -99,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
@@ -119,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
+69 -58
View File
@@ -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.
+6 -2
View File
@@ -44,7 +44,8 @@ roles:
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. Commit your changes with a descriptive message referencing the issue.
5. Run the project's lint/check command (e.g. `bun run check`, `npm run lint`) and fix ALL errors before proceeding. Build and lint must pass cleanly.
6. Commit your changes with a descriptive message referencing the issue.
output: "List all files changed and provide a summary of the implementation."
frontmatter:
type: object
@@ -62,7 +63,10 @@ roles:
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
@@ -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
View File
@@ -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"
+211
View File
@@ -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 });
});
});
@@ -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,98 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { WorkflowPayload } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { parse } from "yaml";
/**
* Test: Issue #474 - tea pr create fails in git worktree directories
*
* This test verifies that the solve-issue workflow's committer role
* includes the --repo flag when running tea pr create, which fixes
* the "path segment [0] is empty" error in worktree directories.
*/
describe("solve-issue workflow: tea pr create worktree fix", () => {
// Navigate up from packages/cli-workflow to repo root
const workflowPath = join(process.cwd(), "..", "..", ".workflows", "solve-issue.yaml");
test("committer procedure should include --repo flag in tea pr create command", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
expect(workflow.roles.committer).toBeDefined();
const committerProcedure = workflow.roles.committer?.procedure;
expect(committerProcedure).toBeDefined();
// Verify the procedure includes tea pr create with --repo flag
expect(committerProcedure).toContain("tea pr create");
expect(committerProcedure).toContain("--repo");
// Verify the --repo flag appears before or together with tea pr create
// This ensures the command is: tea pr create --repo <owner/repo> ...
const teaPrCreateMatch = committerProcedure?.match(/tea pr create[^\n]*/);
expect(teaPrCreateMatch).not.toBeNull();
if (teaPrCreateMatch) {
const teaCommandLine = teaPrCreateMatch[0];
expect(teaCommandLine).toContain("--repo");
}
});
test("committer procedure should mention repo extraction from git remote", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
const committerProcedure = workflow.roles.committer?.procedure;
expect(committerProcedure).toBeDefined();
// Verify the procedure mentions extracting repo info from git remote
// This ensures fallback logic is documented
expect(committerProcedure).toMatch(/git remote/i);
});
test("committer procedure should include error handling for tea failures", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
const committerProcedure = workflow.roles.committer?.procedure;
expect(committerProcedure).toBeDefined();
// Verify the procedure includes error handling guidance
// This ensures we capture failures and provide actionable output
expect(committerProcedure).toMatch(/error|fail/i);
});
test("workflow should be parseable as valid WorkflowPayload", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
const workflow = parse(yamlContent) as WorkflowPayload;
// Basic structure validation
expect(workflow.name).toBe("solve-issue");
expect(workflow.roles).toBeDefined();
expect(workflow.conditions).toBeDefined();
expect(workflow.graph).toBeDefined();
// Verify committer role exists with required fields
expect(workflow.roles.committer).toBeDefined();
expect(workflow.roles.committer?.description).toBeDefined();
expect(workflow.roles.committer?.goal).toBeDefined();
expect(workflow.roles.committer?.procedure).toBeDefined();
expect(workflow.roles.committer?.output).toBeDefined();
expect(workflow.roles.committer?.frontmatter).toBeDefined();
});
test("committer frontmatter schema should require success field", async () => {
const yamlContent = await readFile(workflowPath, "utf-8");
// Parse as any to access the raw YAML structure (frontmatter is inline JSON Schema in YAML)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const workflow = parse(yamlContent) as any;
const frontmatter = workflow.roles.committer?.frontmatter;
expect(frontmatter).toBeDefined();
expect(frontmatter?.type).toBe("object");
expect(frontmatter?.properties?.success).toBeDefined();
expect(frontmatter?.properties?.success?.type).toBe("boolean");
expect(frontmatter?.required).toContain("success");
});
});
@@ -0,0 +1,519 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdStepRead } from "../commands/step.js";
import { registerUwfSchemas } from "../schemas.js";
// ── schemas used in tests ────────────────────────────────────────────────────
const TURN_SCHEMA = {
title: "hermes-turn",
type: "object" as const,
required: ["index", "role", "content"],
properties: {
index: { type: "integer" as const },
role: { type: "string" as const },
content: { type: "string" as const },
toolCalls: {
anyOf: [
{ type: "array" as const, items: { type: "object" as const } },
{ type: "null" as const },
],
},
reasoning: { anyOf: [{ type: "string" as const }, { type: "null" as const }] },
},
additionalProperties: false,
};
const DETAIL_SCHEMA = {
title: "hermes-detail",
type: "object" as const,
required: ["sessionId", "model", "duration", "turnCount", "turns"],
properties: {
sessionId: { type: "string" as const },
model: { type: "string" as const },
duration: { type: "integer" as const },
turnCount: { type: "integer" as const },
turns: {
type: "array" as const,
items: { type: "string" as const, format: "cas_ref" },
},
},
additionalProperties: false,
};
// ── helpers ───────────────────────────────────────────────────────────────────
async function registerDetailSchemas(store: ReturnType<typeof createFsStore>) {
await bootstrap(store);
const [turn, detail] = await Promise.all([
putSchema(store, TURN_SCHEMA),
putSchema(store, DETAIL_SCHEMA),
]);
return { turn, detail };
}
function generateContent(size: number, prefix = "Content"): string {
const base = `${prefix} `;
const repeat = Math.ceil(size / base.length);
return base.repeat(repeat).slice(0, size);
}
// ── fixture ───────────────────────────────────────────────────────────────────
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-step-read-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ── step read tests ───────────────────────────────────────────────────────────
describe("step read", () => {
test("test 1: basic single-step read with 3 turns", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do the work.",
output: "Summarize the work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test task",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// Create 3 turns
const turnHashes: CasRef[] = [];
for (let i = 1; i <= 3; i++) {
const content = `Turn ${i} content with some text to make it readable.`;
const turnHash = await store.put(detailSchemas.turn, {
index: i - 1,
role: "assistant",
content,
toolCalls: null,
reasoning: null,
});
turnHashes.push(turnHash);
}
const detailHash = await store.put(detailSchemas.detail, {
sessionId: "session-1",
model: "test-model",
duration: 1000,
turnCount: 3,
turns: turnHashes,
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
});
// Read step with large quota
const markdown = await cmdStepRead(tmpDir, stepHash, 10000);
// Assert structure
expect(markdown).toContain(`# Step ${stepHash}`);
expect(markdown).toContain("**Role:** worker");
expect(markdown).toContain("**Agent:** uwf-test");
expect(markdown).toContain("## Turn 1");
expect(markdown).toContain("## Turn 2");
expect(markdown).toContain("## Turn 3");
expect(markdown).toContain("Turn 1 content with some text to make it readable.");
expect(markdown).toContain("Turn 2 content with some text to make it readable.");
expect(markdown).toContain("Turn 3 content with some text to make it readable.");
});
test("test 2: quota enforcement - multiple turns", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do the work.",
output: "Summarize the work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test task",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// Create 4 turns of ~300 chars each
const turnHashes: CasRef[] = [];
for (let i = 1; i <= 4; i++) {
const content = generateContent(300, `Turn${i}`);
const turnHash = await store.put(detailSchemas.turn, {
index: i - 1,
role: "assistant",
content,
toolCalls: null,
reasoning: null,
});
turnHashes.push(turnHash);
}
const detailHash = await store.put(detailSchemas.detail, {
sessionId: "session-1",
model: "test-model",
duration: 1000,
turnCount: 4,
turns: turnHashes,
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
});
// Read step with limited quota (700 chars)
const markdown = await cmdStepRead(tmpDir, stepHash, 700);
// Assert only most recent turns fit
expect(markdown).toContain(`# Step ${stepHash}`);
// Should have skip hint
expect(markdown).toContain("Earlier turns omitted");
// Should include at least Turn 4 (most recent)
expect(markdown).toContain("Turn4");
// Total length should respect quota (with tolerance for structural overhead)
expect(markdown.length).toBeLessThanOrEqual(900); // 700 quota + 200 buffer tolerance
});
test("test 3: minimal quota edge case - always show at least one turn", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do the work.",
output: "Summarize the work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test task",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// Create 1 turn of 500 chars
const content = generateContent(500, "LongTurn");
const turnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content,
toolCalls: null,
reasoning: null,
});
const detailHash = await store.put(detailSchemas.detail, {
sessionId: "session-1",
model: "test-model",
duration: 1000,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
});
// Read step with minimal quota (1 char)
const markdown = await cmdStepRead(tmpDir, stepHash, 1);
// Assert at least one turn is always shown
expect(markdown).toContain("LongTurn");
expect(markdown.length).toBeGreaterThan(1);
});
test("test 4: step with no detail field", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do the work.",
output: "Summarize the work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test task",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: null,
agent: "uwf-test",
});
// Read step - should return metadata only (no error)
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
// Assert metadata is present
expect(markdown).toContain(`# Step ${stepHash}`);
expect(markdown).toContain("**Role:** worker");
expect(markdown).toContain("**Agent:** uwf-test");
// Should not have turn sections
expect(markdown).not.toContain("## Turn");
});
test("test 5: step with detail but no turns array", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do the work.",
output: "Summarize the work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test task",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// Create detail with different schema (no turns)
const SIMPLE_DETAIL_SCHEMA = {
title: "simple-detail",
type: "object" as const,
required: ["sessionId"],
properties: {
sessionId: { type: "string" as const },
},
additionalProperties: false,
};
await bootstrap(store);
const simpleDetailType = await putSchema(store, SIMPLE_DETAIL_SCHEMA);
const detailHash = await store.put(simpleDetailType, {
sessionId: "session-1",
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
});
// Read step - should return metadata only (no error)
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
// Assert metadata is present
expect(markdown).toContain(`# Step ${stepHash}`);
expect(markdown).toContain("**Role:** worker");
// Should not have turn sections
expect(markdown).not.toContain("## Turn");
});
test("test 6: turn content with special characters", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do the work.",
output: "Summarize the work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test task",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// Create turn with special markdown characters
const content = "This has `backticks`, **bold**, *italic*, and [links](http://example.com)";
const turnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content,
toolCalls: null,
reasoning: null,
});
const detailHash = await store.put(detailSchemas.detail, {
sessionId: "session-1",
model: "test-model",
duration: 1000,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
});
// Read step
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
// Assert content is rendered correctly without corruption
expect(markdown).toContain("`backticks`");
expect(markdown).toContain("**bold**");
expect(markdown).toContain("*italic*");
expect(markdown).toContain("[links](http://example.com)");
});
});
@@ -0,0 +1,550 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { extractUlidTimestamp, generateUlid } from "@uncaged/workflow-util";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createMarker, deleteMarker } from "../background/index.js";
import { cmdThreadList } from "../commands/thread.js";
import { parseTimeInput } from "../commands/thread-time-parser.js";
import type { UwfStore } from "../store.js";
import { appendThreadHistory, createUwfStore, saveThreadsIndex } from "../store.js";
// ── helpers ───────────────────────────────────────────────────────────────────
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = join(storageRoot, "cas");
await mkdir(casDir, { recursive: true });
return createUwfStore(storageRoot);
}
async function createTestWorkflow(uwf: UwfStore): Promise<CasRef> {
const workflowPayload = {
name: "test-workflow",
roles: {
role1: {
goal: "test goal",
outputSchema: { type: "object" as const, properties: {} },
},
},
graph: { start: "role1" },
conditions: {},
};
return await uwf.store.put(uwf.schemas.workflow, workflowPayload);
}
async function createTestThread(
uwf: UwfStore,
storageRoot: string,
workflowHash: CasRef,
timestamp: number,
): Promise<ThreadId> {
const threadId = generateUlid(timestamp) as ThreadId;
const startPayload = {
workflow: workflowHash,
prompt: "test prompt",
};
const headHash = await uwf.store.put(uwf.schemas.startNode, startPayload);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
index[threadId] = headHash;
await saveThreadsIndex(storageRoot, index);
return threadId;
}
async function markThreadRunning(storageRoot: string, threadId: ThreadId, workflow: CasRef) {
await createMarker(storageRoot, {
thread: threadId,
workflow,
pid: process.pid, // Use current process PID so isPidAlive returns true
startedAt: Date.now(),
});
}
async function completeThread(
storageRoot: string,
threadId: ThreadId,
workflowHash: CasRef,
headHash: CasRef,
) {
const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
delete index[threadId];
await saveThreadsIndex(storageRoot, index);
await appendThreadHistory(storageRoot, {
thread: threadId,
workflow: workflowHash,
head: headHash,
completedAt: Date.now(),
});
}
// ── test setup ────────────────────────────────────────────────────────────────
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "thread-list-filters-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ── status filter tests ───────────────────────────────────────────────────────
describe("cmdThreadList status filter", () => {
test("should return idle and running threads when status=active", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
await markThreadRunning(tmpDir, thread2, workflowHash);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
const thread3Head = index[thread3];
if (thread3Head === undefined) throw new Error("thread3 head not found");
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
const result = await cmdThreadList(tmpDir, ["idle", "running"], null, null, null, null);
expect(result).toHaveLength(2);
expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread2].sort());
// Clean up marker after test
await deleteMarker(tmpDir, thread2);
});
test("should support comma-separated status values", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
await markThreadRunning(tmpDir, thread2, workflowHash);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
const thread3Head = index[thread3];
if (thread3Head === undefined) throw new Error("thread3 head not found");
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
const result = await cmdThreadList(tmpDir, ["idle", "completed"], null, null, null, null);
// Clean up marker
await deleteMarker(tmpDir, thread2);
// thread2 is running (not idle), so should not be included
// Expected: thread1 (idle) and thread3 (completed)
expect(result).toHaveLength(2);
expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread3].sort());
});
test("should support single status filter (backward compat)", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const _thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
const _thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
const thread3Head = index[thread3];
if (thread3Head === undefined) throw new Error("thread3 head not found");
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
const result = await cmdThreadList(tmpDir, ["completed"], null, null, null, null);
expect(result).toHaveLength(1);
expect(result[0]?.thread).toBe(thread3);
expect(result[0]?.status).toBe("completed");
});
test("should return all threads when no status filter provided", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
await markThreadRunning(tmpDir, thread2, workflowHash);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
const thread3Head = index[thread3];
if (thread3Head === undefined) throw new Error("thread3 head not found");
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
const result = await cmdThreadList(tmpDir, null, null, null, null, null);
expect(result).toHaveLength(3);
expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread2, thread3].sort());
});
});
// ── time range filtering tests ────────────────────────────────────────────────
describe("cmdThreadList time filters", () => {
test("should filter threads created after given timestamp", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const ts1 = Date.UTC(2026, 4, 20, 0, 0, 0);
const ts2 = Date.UTC(2026, 4, 21, 0, 0, 0);
const ts3 = Date.UTC(2026, 4, 22, 0, 0, 0);
const _threadA = await createTestThread(uwf, tmpDir, workflowHash, ts1);
const threadB = await createTestThread(uwf, tmpDir, workflowHash, ts2);
const threadC = await createTestThread(uwf, tmpDir, workflowHash, ts3);
// Use a timestamp slightly before ts2 to include threadB
const afterMs = Date.UTC(2026, 4, 20, 12, 0, 0);
const result = await cmdThreadList(tmpDir, null, afterMs, null, null, null);
expect(result).toHaveLength(2);
expect(result.map((r) => r.thread).sort()).toEqual([threadB, threadC].sort());
});
test("should filter threads created before given timestamp", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const ts1 = Date.UTC(2026, 4, 20, 0, 0, 0);
const ts2 = Date.UTC(2026, 4, 21, 0, 0, 0);
const ts3 = Date.UTC(2026, 4, 22, 0, 0, 0);
const threadA = await createTestThread(uwf, tmpDir, workflowHash, ts1);
const threadB = await createTestThread(uwf, tmpDir, workflowHash, ts2);
const _threadC = await createTestThread(uwf, tmpDir, workflowHash, ts3);
const beforeMs = Date.UTC(2026, 4, 22, 0, 0, 0);
const result = await cmdThreadList(tmpDir, null, null, beforeMs, null, null);
expect(result).toHaveLength(2);
expect(result.map((r) => r.thread).sort()).toEqual([threadA, threadB].sort());
});
test("should support both after and before filters (time range)", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const ts1 = Date.UTC(2026, 4, 20, 0, 0, 0);
const ts2 = Date.UTC(2026, 4, 21, 0, 0, 0);
const ts3 = Date.UTC(2026, 4, 22, 0, 0, 0);
const _threadA = await createTestThread(uwf, tmpDir, workflowHash, ts1);
const threadB = await createTestThread(uwf, tmpDir, workflowHash, ts2);
const _threadC = await createTestThread(uwf, tmpDir, workflowHash, ts3);
const afterMs = Date.UTC(2026, 4, 20, 12, 0, 0);
const beforeMs = Date.UTC(2026, 4, 22, 0, 0, 0);
const result = await cmdThreadList(tmpDir, null, afterMs, beforeMs, null, null);
expect(result).toHaveLength(1);
expect(result[0]?.thread).toBe(threadB);
});
});
// ── pagination tests ──────────────────────────────────────────────────────────
describe("cmdThreadList pagination", () => {
test("should limit results with --take", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const threads: ThreadId[] = [];
for (let i = 0; i < 10; i++) {
threads.push(await createTestThread(uwf, tmpDir, workflowHash, Date.now() - i * 1000));
}
const result = await cmdThreadList(tmpDir, null, null, null, null, 5);
expect(result).toHaveLength(5);
});
test("should skip first N threads with --skip", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const threads: ThreadId[] = [];
// Create threads in chronological order, but they'll be sorted newest first
for (let i = 0; i < 10; i++) {
threads.push(await createTestThread(uwf, tmpDir, workflowHash, Date.now() + i * 100));
// Small delay to ensure distinct timestamps
await new Promise((resolve) => setTimeout(resolve, 10));
}
const result = await cmdThreadList(tmpDir, null, null, null, 3, null);
expect(result).toHaveLength(7);
// The 3 newest threads should be skipped, so we should get the 7 oldest
});
test("should support skip + take for pagination", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const threads: ThreadId[] = [];
for (let i = 0; i < 10; i++) {
threads.push(await createTestThread(uwf, tmpDir, workflowHash, Date.now() + i * 100));
await new Promise((resolve) => setTimeout(resolve, 10));
}
const result = await cmdThreadList(tmpDir, null, null, null, 5, 3);
expect(result).toHaveLength(3);
// Should skip first 5 (newest), then take 3
});
test("should handle take > available threads", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const _thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
const _thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
const _thread3 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
const result = await cmdThreadList(tmpDir, null, null, null, null, 10);
expect(result).toHaveLength(3);
});
test("should return empty array when skip >= thread count", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 3000);
await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
const result = await cmdThreadList(tmpDir, null, null, null, 5, null);
expect(result).toHaveLength(0);
});
});
// ── combined filters tests ────────────────────────────────────────────────────
describe("combined filters", () => {
test("should combine status and time range filters", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const ts1 = Date.UTC(2026, 4, 20, 0, 0, 0);
const ts2 = Date.UTC(2026, 4, 21, 0, 0, 0);
const ts3 = Date.UTC(2026, 4, 22, 0, 0, 0);
const ts4 = Date.UTC(2026, 4, 23, 0, 0, 0);
const _thread1 = await createTestThread(uwf, tmpDir, workflowHash, ts1);
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, ts2);
const thread3 = await createTestThread(uwf, tmpDir, workflowHash, ts3);
const thread4 = await createTestThread(uwf, tmpDir, workflowHash, ts4);
await markThreadRunning(tmpDir, thread2, workflowHash);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
const thread3Head = index[thread3];
if (thread3Head === undefined) throw new Error("thread3 head not found");
await completeThread(tmpDir, thread3, workflowHash, thread3Head);
const afterMs = Date.UTC(2026, 4, 20, 12, 0, 0);
const result = await cmdThreadList(tmpDir, ["idle"], afterMs, null, null, null);
expect(result).toHaveLength(1);
expect(result[0]?.thread).toBe(thread4);
expect(result[0]?.status).toBe("idle");
// Clean up marker
await deleteMarker(tmpDir, thread2);
});
test("should combine status filter and pagination", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const threads: ThreadId[] = [];
for (let i = 9; i >= 0; i--) {
const thread = await createTestThread(uwf, tmpDir, workflowHash, Date.now() + i * 1000);
threads.push(thread);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
const headHash = index[thread];
if (headHash === undefined) throw new Error("head not found");
await completeThread(tmpDir, thread, workflowHash, headHash);
}
const result = await cmdThreadList(tmpDir, ["completed"], null, null, 3, 5);
expect(result).toHaveLength(5);
for (const r of result) {
expect(r.status).toBe("completed");
}
});
test("should combine time range and pagination", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const threads: ThreadId[] = [];
for (let i = 0; i < 20; i++) {
const ts = Date.UTC(2026, 4, 1 + i, 0, 0, 0);
threads.push(await createTestThread(uwf, tmpDir, workflowHash, ts));
}
const afterMs = Date.UTC(2026, 4, 10, 0, 0, 0);
const result = await cmdThreadList(tmpDir, null, afterMs, null, 2, 5);
expect(result).toHaveLength(5);
for (const r of result) {
const ts = extractUlidTimestamp(r.thread);
expect(ts).not.toBeNull();
if (ts !== null) {
expect(ts).toBeGreaterThan(afterMs);
}
}
});
async function setupMixedStatusThreads(
uwf: UwfStore,
workflowHash: string,
count: number,
): Promise<ThreadId[]> {
const threads: ThreadId[] = [];
for (let i = 0; i < count; i++) {
const ts = Date.UTC(2026, 4, 10 + i, 0, 0, 0);
const thread = await createTestThread(uwf, tmpDir, workflowHash, ts);
threads.push(thread);
if (i % 2 === 0) {
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
const headHash = index[thread];
if (headHash === undefined) throw new Error("head not found");
await completeThread(tmpDir, thread, workflowHash, headHash);
} else {
await markThreadRunning(tmpDir, thread, workflowHash);
}
}
return threads;
}
async function cleanupRunningMarkers(threads: ThreadId[]): Promise<void> {
for (let i = 0; i < threads.length; i++) {
if (i % 2 !== 0) {
await deleteMarker(tmpDir, threads[i] as ThreadId);
}
}
}
test("should combine all filters (status + time + pagination)", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const threads = await setupMixedStatusThreads(uwf, workflowHash, 15);
const afterMs = Date.UTC(2026, 4, 14, 12, 0, 0);
const beforeMs = Date.UTC(2026, 4, 20, 0, 0, 0);
const result = await cmdThreadList(tmpDir, ["idle", "running"], afterMs, beforeMs, 1, 3);
expect(result.length).toBeLessThanOrEqual(3);
for (const r of result) {
expect(["idle", "running"]).toContain(r.status);
const ts = extractUlidTimestamp(r.thread);
if (ts !== null) {
expect(ts).toBeGreaterThan(afterMs);
expect(ts).toBeLessThan(beforeMs);
}
}
await cleanupRunningMarkers(threads);
});
});
// ── edge cases tests ──────────────────────────────────────────────────────────
describe("edge cases", () => {
test("should handle empty thread list", async () => {
await makeUwfStore(tmpDir);
const result = await cmdThreadList(tmpDir, null, null, null, null, null);
expect(result).toHaveLength(0);
});
test("should skip threads with invalid ULID when time filtering", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await createTestWorkflow(uwf);
const thread1 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 2000);
const thread2 = await createTestThread(uwf, tmpDir, workflowHash, Date.now() - 1000);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(tmpDir));
index["INVALID_ULID_FORMAT_HERE" as ThreadId] = "01J6HMVRNQKJV2";
await saveThreadsIndex(tmpDir, index);
const afterMs = Date.now() - 3000;
const result = await cmdThreadList(tmpDir, null, afterMs, null, null, null);
expect(result).toHaveLength(2);
expect(result.map((r) => r.thread).sort()).toEqual([thread1, thread2].sort());
});
});
// ── time parsing tests ────────────────────────────────────────────────────────
describe("relative time parsing", () => {
test("should parse '7d' as 7 days ago", () => {
const nowMs = Date.UTC(2026, 4, 24, 12, 0, 0);
const result = parseTimeInput("7d", nowMs);
const expected = Date.UTC(2026, 4, 17, 12, 0, 0);
expect(result).toBe(expected);
});
test("should parse '24h' as 24 hours ago", () => {
const nowMs = Date.UTC(2026, 4, 24, 12, 0, 0);
const result = parseTimeInput("24h", nowMs);
const expected = Date.UTC(2026, 4, 23, 12, 0, 0);
expect(result).toBe(expected);
});
test("should parse '30m' as 30 minutes ago", () => {
const nowMs = Date.UTC(2026, 4, 24, 12, 30, 0);
const result = parseTimeInput("30m", nowMs);
const expected = Date.UTC(2026, 4, 24, 12, 0, 0);
expect(result).toBe(expected);
});
test("should parse '1d' as 1 day ago", () => {
const nowMs = Date.UTC(2026, 4, 24, 0, 0, 0);
const result = parseTimeInput("1d", nowMs);
const expected = Date.UTC(2026, 4, 23, 0, 0, 0);
expect(result).toBe(expected);
});
});
describe("ISO date parsing", () => {
test("should parse ISO date (YYYY-MM-DD)", () => {
const nowMs = Date.now();
const result = parseTimeInput("2026-05-20", nowMs);
const expected = Date.UTC(2026, 4, 20, 0, 0, 0);
expect(result).toBe(expected);
});
test("should parse ISO datetime (YYYY-MM-DDTHH:MM:SS)", () => {
const nowMs = Date.now();
const result = parseTimeInput("2026-05-20T14:30:00", nowMs);
const expected = Date.parse("2026-05-20T14:30:00");
expect(result).toBe(expected);
});
test("should parse ISO datetime with Z suffix", () => {
const nowMs = Date.now();
const result = parseTimeInput("2026-05-20T14:30:00Z", nowMs);
const expected = Date.UTC(2026, 4, 20, 14, 30, 0);
expect(result).toBe(expected);
});
test("should reject invalid date formats", () => {
const nowMs = Date.now();
expect(() => parseTimeInput("not-a-date", nowMs)).toThrow();
expect(() => parseTimeInput("2026-13-01", nowMs)).toThrow();
expect(() => parseTimeInput("invalid", nowMs)).toThrow();
});
});
@@ -0,0 +1,583 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdThreadRead } from "../commands/thread.js";
import { registerUwfSchemas } from "../schemas.js";
import { saveThreadsIndex } from "../store.js";
// ── schemas used in tests ────────────────────────────────────────────────────
const TURN_SCHEMA = {
title: "hermes-turn",
type: "object" as const,
required: ["index", "role", "content"],
properties: {
index: { type: "integer" as const },
role: { type: "string" as const },
content: { type: "string" as const },
toolCalls: {
anyOf: [
{ type: "array" as const, items: { type: "object" as const } },
{ type: "null" as const },
],
},
reasoning: { anyOf: [{ type: "string" as const }, { type: "null" as const }] },
},
additionalProperties: false,
};
const DETAIL_SCHEMA = {
title: "hermes-detail",
type: "object" as const,
required: ["sessionId", "model", "duration", "turnCount", "turns"],
properties: {
sessionId: { type: "string" as const },
model: { type: "string" as const },
duration: { type: "integer" as const },
turnCount: { type: "integer" as const },
turns: {
type: "array" as const,
items: { type: "string" as const, format: "cas_ref" },
},
},
additionalProperties: false,
};
// ── helpers ───────────────────────────────────────────────────────────────────
async function registerDetailSchemas(store: ReturnType<typeof createFsStore>) {
await bootstrap(store);
const [turn, detail] = await Promise.all([
putSchema(store, TURN_SCHEMA),
putSchema(store, DETAIL_SCHEMA),
]);
return { turn, detail };
}
function generateContent(size: number, prefix = "Content"): string {
const base = `${prefix} `;
const repeat = Math.ceil(size / base.length);
return base.repeat(repeat).slice(0, size);
}
// ── fixture ───────────────────────────────────────────────────────────────────
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-quota-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ── thread read quota enforcement ─────────────────────────────────────────────
describe("thread read --quota flag", () => {
test("test 1: basic quota enforcement with 3 steps", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do the work.",
output: "Summarize the work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test task",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// Create 3 steps with ~500 chars each
const steps: CasRef[] = [];
for (let i = 1; i <= 3; i++) {
const content = generateContent(500, `Step${i}`);
const turnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content,
toolCalls: null,
reasoning: null,
});
const detailHash = await store.put(detailSchemas.detail, {
sessionId: `session-${i}`,
model: "test-model",
duration: 1000,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: steps[i - 2] ?? null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
});
steps.push(stepHash);
}
const threadId = "01HX2Q3R4S5T6V7W8X9YZ0" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: steps[2] as CasRef });
// Set quota to 800 chars - should only fit most recent steps
const markdown = await cmdThreadRead(tmpDir, threadId, 800, null, false);
// Quota must be reasonably enforced (allow ~200 char tolerance for skip hint)
expect(markdown.length).toBeLessThanOrEqual(1000);
// Should contain skip hint since not all steps fit
expect(markdown).toMatch(/earlier step/);
// Most recent step should be included
expect(markdown).toMatch(/Step3/);
});
test("test 2: quota check order - verifies bug is fixed", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do the work.",
output: "Summarize the work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test task",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// Create 2 steps: first=300 chars, second=600 chars
const step1Content = generateContent(300, "First");
const step1TurnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content: step1Content,
toolCalls: null,
reasoning: null,
});
const step1DetailHash = await store.put(detailSchemas.detail, {
sessionId: "session-1",
model: "test-model",
duration: 1000,
turnCount: 1,
turns: [step1TurnHash],
});
const step1Hash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: step1DetailHash,
agent: "uwf-test",
});
const step2Content = generateContent(600, "Second");
const step2TurnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content: step2Content,
toolCalls: null,
reasoning: null,
});
const step2DetailHash = await store.put(detailSchemas.detail, {
sessionId: "session-2",
model: "test-model",
duration: 1000,
turnCount: 1,
turns: [step2TurnHash],
});
const step2Hash = await store.put(schemas.stepNode, {
start: startHash,
prev: step1Hash,
role: "worker",
output: outputHash,
detail: step2DetailHash,
agent: "uwf-test",
});
const threadId = "01HX2Q3R4S5T6V7W8X9YZ1" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: step2Hash });
// Set quota to 500 chars
const markdown = await cmdThreadRead(tmpDir, threadId, 500, null, false);
// Bug fix verification: output must be limited (allow ~200 char tolerance)
expect(markdown.length).toBeLessThanOrEqual(1100);
// Should contain "Second" (most recent step)
expect(markdown).toMatch(/Second/);
// Should skip first step
expect(markdown).toMatch(/earlier step/);
// Verify improvement: before fix would be ~1264, now should be much closer to 500
expect(markdown.length).toBeLessThan(1200);
});
test("test 3: quota with --start section", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do the work.",
output: "Summarize the work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test task with a moderately long prompt to test quota accounting",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// Create 2 steps
const steps: CasRef[] = [];
for (let i = 1; i <= 2; i++) {
const content = generateContent(400, `Step${i}`);
const turnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content,
toolCalls: null,
reasoning: null,
});
const detailHash = await store.put(detailSchemas.detail, {
sessionId: `session-${i}`,
model: "test-model",
duration: 1000,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: steps[i - 2] ?? null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
});
steps.push(stepHash);
}
const threadId = "01HX2Q3R4S5T6V7W8X9YZ2" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: steps[1] as CasRef });
// Set tight quota with --start flag
const markdown = await cmdThreadRead(tmpDir, threadId, 600, null, true);
// Quota must be reasonably enforced (allow ~210 char tolerance for structure)
expect(markdown.length).toBeLessThanOrEqual(810);
// Should contain thread header
expect(markdown).toMatch(/# Thread/);
expect(markdown).toMatch(/test-wf/);
});
test("test 5a: quota edge case - minimal quota", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do the work.",
output: "Summarize the work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test task",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const content = generateContent(500, "Test");
const turnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content,
toolCalls: null,
reasoning: null,
});
const detailHash = await store.put(detailSchemas.detail, {
sessionId: "session-1",
model: "test-model",
duration: 1000,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
});
const threadId = "01HX2Q3R4S5T6V7W8X9YZ4" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
// Minimal quota
const markdown = await cmdThreadRead(tmpDir, threadId, 1, null, false);
// Should handle gracefully - always shows at least one step
expect(markdown.length).toBeGreaterThan(1);
expect(markdown).toMatch(/Test/);
});
test("test 5b: quota edge case - very large quota", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do the work.",
output: "Summarize the work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test task",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// Create 3 steps
const steps: CasRef[] = [];
for (let i = 1; i <= 3; i++) {
const content = generateContent(300, `Step${i}`);
const turnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content,
toolCalls: null,
reasoning: null,
});
const detailHash = await store.put(detailSchemas.detail, {
sessionId: `session-${i}`,
model: "test-model",
duration: 1000,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: steps[i - 2] ?? null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
});
steps.push(stepHash);
}
const threadId = "01HX2Q3R4S5T6V7W8X9YZ5" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: steps[2] as CasRef });
// Very large quota
const markdown = await cmdThreadRead(tmpDir, threadId, 1000000, null, false);
// Should show all steps (no skipping)
expect(markdown).not.toMatch(/earlier step/);
expect(markdown).toMatch(/Step1/);
expect(markdown).toMatch(/Step2/);
expect(markdown).toMatch(/Step3/);
});
test("test 6: quota with --before parameter", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const detailSchemas = await registerDetailSchemas(store);
const workflowHash = await store.put(schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do the work.",
output: "Summarize the work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test task",
});
const outputHash = await store.put(schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// Create 5 steps
const steps: CasRef[] = [];
for (let i = 1; i <= 5; i++) {
const content = generateContent(300, `Step${i}`);
const turnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content,
toolCalls: null,
reasoning: null,
});
const detailHash = await store.put(detailSchemas.detail, {
sessionId: `session-${i}`,
model: "test-model",
duration: 1000,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: steps[i - 2] ?? null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
});
steps.push(stepHash);
}
const threadId = "01HX2Q3R4S5T6V7W8X9YZ6" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: steps[4] as CasRef });
// Use --before to limit to steps 1-2, then set quota that allows only 1
const markdown = await cmdThreadRead(tmpDir, threadId, 500, steps[2] as CasRef, false);
// Should not contain Step3 or later
expect(markdown).not.toMatch(/Step3/);
expect(markdown).not.toMatch(/Step4/);
expect(markdown).not.toMatch(/Step5/);
// Quota should select most recent of candidates (Step2)
expect(markdown).toMatch(/Step2/);
// Quota enforcement (allow ~200 char tolerance)
expect(markdown.length).toBeLessThanOrEqual(700);
});
});
@@ -0,0 +1,683 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdThreadRead, THREAD_READ_DEFAULT_QUOTA } from "../commands/thread.js";
import { registerUwfSchemas } from "../schemas.js";
import type { UwfStore } from "../store.js";
import { saveThreadsIndex } from "../store.js";
// ── schemas used in tests ────────────────────────────────────────────────────
const TURN_SCHEMA = {
title: "hermes-turn",
type: "object" as const,
required: ["index", "role", "content"],
properties: {
index: { type: "integer" as const },
role: { type: "string" as const },
content: { type: "string" as const },
toolCalls: {
anyOf: [
{ type: "array" as const, items: { type: "object" as const } },
{ type: "null" as const },
],
},
reasoning: { anyOf: [{ type: "string" as const }, { type: "null" as const }] },
},
additionalProperties: false,
};
const DETAIL_SCHEMA = {
title: "hermes-detail",
type: "object" as const,
required: ["sessionId", "model", "duration", "turnCount", "turns"],
properties: {
sessionId: { type: "string" as const },
model: { type: "string" as const },
duration: { type: "integer" as const },
turnCount: { type: "integer" as const },
turns: {
type: "array" as const,
items: { type: "string" as const, format: "cas_ref" },
},
},
additionalProperties: false,
};
// ── helpers ───────────────────────────────────────────────────────────────────
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = join(storageRoot, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
return { storageRoot, store, schemas };
}
async function registerDetailSchemas(store: ReturnType<typeof createFsStore>) {
await bootstrap(store);
const [turn, detail] = await Promise.all([
putSchema(store, TURN_SCHEMA),
putSchema(store, DETAIL_SCHEMA),
]);
return { turn, detail };
}
// ── fixture ───────────────────────────────────────────────────────────────────
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ── thread read XML tag isolation ─────────────────────────────────────────────
describe("thread read XML tag isolation", () => {
test("scenario 1: wraps output in XML tags instead of heading", async () => {
const uwf = await makeUwfStore(tmpDir);
const detailSchemas = await registerDetailSchemas(uwf.store);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
planner: {
description: "Planner",
goal: "You are a planning agent. Your task is to...",
capabilities: [],
procedure: "Plan the work.",
output: "Summarize the plan.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Fix issue #459",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const turnHash = await uwf.store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content:
"---\nstatus: ready\nplan: CMWGHQKT58RY4\n---\n\n# Analysis Complete\n## Issue Summary\nThe issue requires XML tag isolation.",
toolCalls: null,
reasoning: null,
});
const detailHash = await uwf.store.put(detailSchemas.detail, {
sessionId: "sx",
model: "mx",
duration: 500,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "planner",
output: outputHash,
detail: detailHash,
agent: "uwf-claude-code",
});
const threadId = "01JTEST0000000000000001" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
// Should wrap output in XML tags
expect(markdown).toContain("<output>");
expect(markdown).toContain("</output>");
// Should not have ### Content heading
expect(markdown).not.toContain("### Content");
// Should preserve markdown headings inside output tags
expect(markdown).toContain("# Analysis Complete");
expect(markdown).toContain("## Issue Summary");
});
test("scenario 2: wraps prompt in XML tags", async () => {
const uwf = await makeUwfStore(tmpDir);
const detailSchemas = await registerDetailSchemas(uwf.store);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
planner: {
description: "Planner",
goal: "You are a planning agent. Your task is to analyze and plan.",
capabilities: [],
procedure: "Plan the work.",
output: "Summarize the plan.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Fix issue",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const turnHash = await uwf.store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content: "---\nstatus: ready\n---\n\nContent here...",
toolCalls: null,
reasoning: null,
});
const detailHash = await uwf.store.put(detailSchemas.detail, {
sessionId: "sx",
model: "mx",
duration: 500,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "planner",
output: outputHash,
detail: detailHash,
agent: "uwf-claude-code",
});
const threadId = "01JTEST0000000000000002" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
// Should wrap prompt in XML tags
expect(markdown).toContain("<prompt>");
expect(markdown).toContain("</prompt>");
expect(markdown).toContain("You are a planning agent. Your task is to analyze and plan.");
// Should not have ### Prompt heading
expect(markdown).not.toContain("### Prompt");
// Should wrap output in XML tags
expect(markdown).toContain("<output>");
expect(markdown).toContain("</output>");
});
test("scenario 3: same role repeated does not show prompt twice", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
writer: {
description: "Writer",
goal: "You are a writer agent.",
capabilities: [],
procedure: "Write content.",
output: "Summarize writing.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Write something",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const step1 = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "writer",
output: outputHash,
detail: null,
agent: "uwf-test",
});
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: step1 as CasRef,
role: "writer",
output: outputHash,
detail: null,
agent: "uwf-test",
});
const threadId = "01JTEST0000000000000003" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: step2 });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
// Should only show prompt tags once
const promptCount = (markdown.match(/<prompt>/g) ?? []).length;
expect(promptCount).toBe(1);
});
test("scenario 4: step with no detail shows no output tags", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
worker: {
description: "Worker",
goal: "You are a worker agent.",
capabilities: [],
procedure: "Do work.",
output: "Summarize work.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Do stuff",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: null,
agent: "uwf-test",
});
const threadId = "01JTEST0000000000000004" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
// Should not have output tags
expect(markdown).not.toContain("<output>");
expect(markdown).not.toContain("</output>");
// Step header should still be displayed
expect(markdown).toContain("## Step 1: worker");
// Prompt should still be shown
expect(markdown).toContain("<prompt>");
});
test("scenario 5: empty content shows no output tags", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Do stuff",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// A detail ref that doesn't exist → extractLastAssistantContent returns null
const missingDetailRef = "missingdetail0" as CasRef;
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: missingDetailRef,
agent: "uwf-test",
});
const threadId = "01JTEST0000000000000005" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
// Should not have output tags
expect(markdown).not.toContain("<output>");
expect(markdown).not.toContain("</output>");
});
test("scenario 6: thread read with --start flag shows task section", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
roleA: {
description: "Role A",
goal: "Goal for roleA",
capabilities: [],
procedure: "Do stuff.",
output: "Output.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Initial prompt",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "roleA",
output: outputHash,
detail: null,
agent: "uwf-test",
});
const threadId = "01JTEST0000000000000006" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, true);
// Should include task section
expect(markdown).toContain("# Thread");
expect(markdown).toContain("## Task");
expect(markdown).toContain("Initial prompt");
// Prompts should use XML tags
expect(markdown).toContain("<prompt>");
});
test("scenario 7: thread read with --before parameter", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
roleA: {
description: "Role A",
goal: "Goal for roleA",
capabilities: [],
procedure: "Do stuff.",
output: "Output.",
meta: "placeholder00" as CasRef,
},
roleB: {
description: "Role B",
goal: "Goal for roleB",
capabilities: [],
procedure: "Do stuff.",
output: "Output.",
meta: "placeholder00" as CasRef,
},
roleC: {
description: "Role C",
goal: "Goal for roleC",
capabilities: [],
procedure: "Do stuff.",
output: "Output.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Initial prompt",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const step1 = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "roleA",
output: outputHash,
detail: null,
agent: "uwf-test",
});
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: step1 as CasRef,
role: "roleB",
output: outputHash,
detail: null,
agent: "uwf-test",
});
const step3 = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: step2 as CasRef,
role: "roleC",
output: outputHash,
detail: null,
agent: "uwf-test",
});
const threadId = "01JTEST0000000000000007" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: step3 });
const markdown = await cmdThreadRead(
tmpDir,
threadId,
THREAD_READ_DEFAULT_QUOTA,
step2 as CasRef,
false,
);
// Should only show roleA
expect(markdown).toContain("roleA");
expect(markdown).not.toContain("roleB");
expect(markdown).not.toContain("roleC");
// Should use XML tags
expect(markdown).toContain("<prompt>");
});
test("scenario 9: special characters in content are preserved", async () => {
const uwf = await makeUwfStore(tmpDir);
const detailSchemas = await registerDetailSchemas(uwf.store);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
writer: {
description: "Writer",
goal: "You are a writer.",
capabilities: [],
procedure: "Write content.",
output: "Summarize.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Write something",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const turnHash = await uwf.store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content: "Content with <special> & characters > like <this>",
toolCalls: null,
reasoning: null,
});
const detailHash = await uwf.store.put(detailSchemas.detail, {
sessionId: "sx",
model: "mx",
duration: 500,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "writer",
output: outputHash,
detail: detailHash,
agent: "uwf-test",
});
const threadId = "01JTEST0000000000000008" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
// Special characters should be preserved as-is
expect(markdown).toContain("Content with <special> & characters > like <this>");
});
test("scenario 10: quota limit with XML tags", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
roleA: {
description: "Role A",
goal: "Goal for roleA",
capabilities: [],
procedure: "Do stuff.",
output: "Output.",
meta: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Initial prompt",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const steps: CasRef[] = [];
let prev: CasRef | null = null;
for (let i = 0; i < 5; i++) {
const step = (await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev,
role: "roleA",
output: outputHash,
detail: null,
agent: "uwf-test",
})) as CasRef;
steps.push(step);
prev = step;
}
const threadId = "01JTEST0000000000000009" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: steps[steps.length - 1]! });
// Use very small quota
const markdown = await cmdThreadRead(tmpDir, threadId, 1, null, false);
// Should have skip hint
expect(markdown).toContain("earlier step");
// Should have XML tags for displayed steps
if (markdown.includes("<prompt>")) {
expect(markdown).toContain("</prompt>");
}
});
});
@@ -22,48 +22,48 @@ function runCli(args: string[]): { stdout: string; stderr: string; exitCode: num
}
}
describe("thread step --count CLI parsing", () => {
describe("thread exec --count CLI parsing", () => {
test("--help shows -c/--count option", () => {
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,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;
};
+305 -57
View File
@@ -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);
}
});
});
+259 -162
View File
@@ -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,75 +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"]).
*/
async function _discoverAgents(): Promise<string[]> {
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 {
// Use which -a to find all uwf-* binaries in PATH
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) {
// Try alternative approach: search PATH directories manually
const pathEnv = process.env.PATH || "";
const pathDirs = pathEnv.split(":").filter((d) => d.length > 0);
const agents = new Set<string>();
for (const dir of pathDirs) {
try {
if (!existsSync(dir)) continue;
const { readdirSync, statSync } = await import("node:fs");
const entries = readdirSync(dir);
for (const entry of entries) {
if (!entry.startsWith("uwf-") || entry === "uwf") continue;
const fullPath = join(dir, entry);
try {
const stat = statSync(fullPath);
// Check if executable (owner, group, or other has execute bit)
if (stat.isFile() && (stat.mode & 0o111) !== 0) {
agents.add(entry);
}
} catch {
// Skip if can't stat
}
}
} catch {
// Skip inaccessible directories
}
}
return Array.from(agents).sort();
}
// Parse which output - each line is a path to a binary
const paths = text
.trim()
.split("\n")
.filter((line) => line.length > 0);
const agents = new Set<string>();
for (const path of paths) {
const basename = path.split("/").pop();
if (basename?.startsWith("uwf-") && basename !== "uwf") {
agents.add(basename);
}
}
return Array.from(agents).sort();
if (proc.exitCode !== 0) return null;
return _parseWhichOutput(text);
} catch {
// If all fails, return empty array
return [];
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.
*/
@@ -281,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);
@@ -292,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);
});
}
@@ -344,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.
*/
@@ -353,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();
@@ -394,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({
@@ -447,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,
};
+256
View File
@@ -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;
}
+405 -367
View File
@@ -1,8 +1,7 @@
import { execFileSync } from "node:child_process";
import { execFileSync, spawn } from "node:child_process";
import { access, readFile } from "node:fs/promises";
import { dirname, isAbsolute, resolve as resolvePath } from "node:path";
import type { Store as CasStore, JSONSchema } from "@uncaged/json-cas";
import { getSchema, validate } from "@uncaged/json-cas";
import { validate } from "@uncaged/json-cas";
import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-agent-kit";
import { evaluate } from "@uncaged/workflow-moderator";
import type {
@@ -10,24 +9,26 @@ import type {
AgentConfig,
CasRef,
ModeratorContext,
StartEntry,
StartNodePayload,
StartOutput,
StepContext,
StepEntry,
StepNodePayload,
StepOutput,
ThreadForkOutput,
ThreadId,
ThreadListItem,
ThreadStepsOutput,
ThreadsIndex,
WorkflowConfig,
WorkflowPayload,
} from "@uncaged/workflow-protocol";
import { createProcessLogger, generateUlid, type ProcessLogger } from "@uncaged/workflow-util";
import {
createProcessLogger,
extractUlidTimestamp,
generateUlid,
type ProcessLogger,
} from "@uncaged/workflow-util";
import { config as loadDotenv } from "dotenv";
import { parse, stringify } from "yaml";
import { parse } from "yaml";
import { createMarker, deleteMarker, isThreadRunning } from "../background/index.js";
import {
appendThreadHistory,
createUwfStore,
@@ -41,6 +42,14 @@ import {
type UwfStore,
} from "../store.js";
import { checkWorkflowFilenameConsistency, isCasRef, parseWorkflowPayload } from "../validate.js";
import {
type ChainState,
collectOrderedSteps,
expandOutput,
fail,
type OrderedStepItem,
walkChain,
} from "./shared.js";
import { materializeWorkflowPayload } from "./workflow.js";
const END_ROLE = "$END";
@@ -52,35 +61,13 @@ const PL_AGENT_SPAWN = "R5J2W8N4";
const PL_AGENT_DONE = "C6P9E3H7";
const PL_THREAD_ARCHIVED = "F4D8Q2K5";
const PL_STEP_ERROR = "B8T5N1V6";
const PL_BACKGROUND_START = "X7Q4W9M2";
function failStep(plog: ProcessLogger, message: string): never {
plog.log(PL_STEP_ERROR, message, null);
fail(message);
}
type ChainState = {
startHash: CasRef;
start: StartNodePayload;
stepsNewestFirst: StepNodePayload[];
headIsStart: boolean;
};
type OrderedStepItem = {
hash: CasRef;
payload: StepNodePayload;
timestamp: number;
};
export type KillOutput = {
thread: ThreadId;
archived: boolean;
};
function fail(message: string): never {
process.stderr.write(`${message}\n`);
process.exit(1);
}
/**
* Check if a string looks like a file path (contains path separators or has .yaml/.yml extension).
*/
@@ -321,6 +308,7 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
thread: threadId,
head: activeHead,
done: false,
background: null,
};
}
@@ -331,230 +319,146 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
thread: threadId,
head: hist.head,
done: true,
background: null,
};
}
fail(`thread not found: ${threadId}`);
}
export type ThreadStatus = "idle" | "running" | "completed";
export type ThreadListItemWithStatus = ThreadListItem & {
status: ThreadStatus;
};
async function threadListItemFromActive(
storageRoot: string,
uwf: UwfStore,
threadId: ThreadId,
head: CasRef,
): Promise<ThreadListItem | null> {
): Promise<ThreadListItemWithStatus | null> {
const workflow = resolveWorkflowFromHead(uwf, head);
if (workflow === null) {
return null;
}
return { thread: threadId, workflow, head };
// Check if thread is currently running in background
const runningMarker = await isThreadRunning(storageRoot, threadId);
const status: ThreadStatus = runningMarker !== null ? "running" : "idle";
return { thread: threadId, workflow, head, status };
}
export async function cmdThreadList(
async function collectActiveThreads(
storageRoot: string,
includeAll: boolean,
): Promise<ThreadListItem[]> {
const uwf = await createUwfStore(storageRoot);
const index = await loadThreadsIndex(storageRoot);
const items: ThreadListItem[] = [];
uwf: UwfStore,
index: ThreadsIndex,
): Promise<ThreadListItemWithStatus[]> {
const items: ThreadListItemWithStatus[] = [];
for (const [threadId, head] of Object.entries(index)) {
const item = await threadListItemFromActive(uwf, threadId as ThreadId, head);
const item = await threadListItemFromActive(
storageRoot,
uwf,
threadId as ThreadId,
head as CasRef,
);
if (item !== null) {
items.push(item);
}
}
return items;
}
if (!includeAll) {
return items;
}
const activeIds = new Set(items.map((i) => i.thread));
async function collectCompletedThreads(
storageRoot: string,
activeIds: Set<ThreadId>,
): Promise<ThreadListItemWithStatus[]> {
const items: ThreadListItemWithStatus[] = [];
const history = await loadThreadHistory(storageRoot);
const seen = new Set<ThreadId>(); // Deduplication (issue #470)
for (const entry of history) {
if (!activeIds.has(entry.thread)) {
if (!activeIds.has(entry.thread) && !seen.has(entry.thread)) {
seen.add(entry.thread);
items.push({
thread: entry.thread,
workflow: entry.workflow,
head: entry.head,
status: "completed",
});
}
}
return items;
}
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 applyTimeFilters(
items: ThreadListItemWithStatus[],
afterMs: number | null,
beforeMs: number | null,
): ThreadListItemWithStatus[] {
if (afterMs === null && beforeMs === null) return items;
return items.filter((item) => {
const ts = extractUlidTimestamp(item.thread);
if (ts === null) return false;
if (afterMs !== null && ts <= afterMs) return false;
if (beforeMs !== null && ts >= beforeMs) return false;
return true;
});
}
function expandOutput(uwf: UwfStore, outputRef: CasRef): unknown {
const node = uwf.store.get(outputRef);
if (node === null) {
return {};
}
return node.payload;
function sortByNewestFirst(items: ThreadListItemWithStatus[]): ThreadListItemWithStatus[] {
return items.sort((a, b) => {
const tsA = extractUlidTimestamp(a.thread) ?? 0;
const tsB = extractUlidTimestamp(b.thread) ?? 0;
return tsB - tsA;
});
}
/**
* 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 applyPagination(
items: ThreadListItemWithStatus[],
skip: number | null,
take: number | null,
): ThreadListItemWithStatus[] {
const skipCount = skip ?? 0;
const takeCount = take ?? items.length;
return items.slice(skipCount, skipCount + takeCount);
}
function expandValue(
store: CasStore,
schema: JSONSchema,
value: unknown,
visited: Set<string>,
): unknown {
// If this field is a cas_ref, expand it
if (schema.format === "cas_ref") {
if (typeof value === "string") {
return expandDeep(store, value as CasRef, visited);
}
return value;
export async function cmdThreadList(
storageRoot: string,
statusFilter: ThreadStatus[] | null,
afterMs: number | null,
beforeMs: number | null,
skip: number | null,
take: number | null,
): Promise<ThreadListItemWithStatus[]> {
const uwf = await createUwfStore(storageRoot);
const index = await loadThreadsIndex(storageRoot);
// Collect active threads
let items = await collectActiveThreads(storageRoot, uwf, index);
// Collect completed threads (if relevant for status filter)
const includeCompleted = statusFilter === null || statusFilter.includes("completed");
if (includeCompleted) {
const activeIds = new Set(items.map((i) => i.thread));
const completedItems = await collectCompletedThreads(storageRoot, activeIds);
items = items.concat(completedItems);
}
// anyOf (nullable refs)
if (Array.isArray(schema.anyOf)) {
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;
// Apply status filter
if (statusFilter !== null) {
items = items.filter((item) => statusFilter.includes(item.status));
}
// Array of cas_ref items
if (schema.type === "array" && schema.items && Array.isArray(value)) {
const itemSchema = schema.items as JSONSchema;
return (value as unknown[]).map((item) => expandValue(store, itemSchema, item, visited));
}
// Apply time range filters
items = applyTimeFilters(items, afterMs, beforeMs);
// Object with properties
if (value !== null && typeof value === "object" && !Array.isArray(value) && schema.properties) {
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;
}
// Sort by timestamp descending (newest first)
items = sortByNewestFirst(items);
return value;
}
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;
}
function formatYaml(value: unknown): string {
return stringify(value, { aliasDuplicateObjects: false }).trimEnd();
}
function formatCompactStep(index: number, item: OrderedStepItem, outputYaml: string): string {
return [
`## Step ${index}: ${item.payload.role}`,
"",
`- **Hash:** \`${item.hash}\``,
`- **Agent:** ${item.payload.agent}`,
"",
"### Output",
"",
"```yaml",
outputYaml,
"```",
].join("\n");
// Apply pagination
return applyPagination(items, skip, take);
}
export function extractLastAssistantContent(uwf: UwfStore, detailRef: CasRef): string | null {
@@ -588,6 +492,123 @@ export function extractLastAssistantContent(uwf: UwfStore, detailRef: CasRef): s
return null;
}
function sliceBeforeHash(
candidates: OrderedStepItem[],
before: CasRef,
threadId: ThreadId,
): OrderedStepItem[] {
const idx = candidates.findIndex((s) => s.hash === before);
if (idx === -1) {
fail(`step ${before} not found in thread ${threadId}`);
}
return candidates.slice(0, idx);
}
function calculateFormattedStepLength(
stepNum: number,
item: OrderedStepItem,
uwf: UwfStore,
workflow: WorkflowPayload,
): number {
// Calculate using the same format as formatStepHeader, formatStepPrompt, formatStepContent
// Use a temporary set to avoid mutating the actual shownPromptRoles during calculation
const tempShownRoles = new Set<string>();
const header = formatStepHeader(stepNum, item);
const roleDef = workflow.roles[item.payload.role];
const prompt = formatStepPrompt(roleDef, item.payload.role, tempShownRoles);
const content = formatStepContent(uwf, item);
const stepBlock = [header, prompt, content].filter((s) => s !== "").join("");
// Don't add separator here - it will be counted when we know the final structure
return stepBlock.length;
}
function selectByQuota(
candidates: OrderedStepItem[],
uwf: UwfStore,
workflow: WorkflowPayload,
quota: number,
startSectionLength: number,
): { selected: OrderedStepItem[]; skippedCount: number } {
const selected: OrderedStepItem[] = [];
// Start with start section length
let totalChars = startSectionLength;
for (let i = candidates.length - 1; i >= 0; i--) {
const item = candidates[i];
if (item === undefined) continue;
// Calculate the actual formatted length using the same format as final output
const blockLen = calculateFormattedStepLength(i + 1, item, uwf, workflow);
// Calculate cost of adding this step:
// - blockLen: the step content
// - 6: separator before this step (if there are already parts)
const separatorCost = totalChars > 0 || selected.length > 0 ? 6 : 0;
const addCost = blockLen + separatorCost;
// Check quota BEFORE adding - but always include at least one step
if (totalChars + addCost > quota && selected.length > 0) {
break;
}
selected.unshift(item);
totalChars += addCost;
}
return { selected, skippedCount: candidates.length - selected.length };
}
function formatStepHeader(stepNum: number, item: OrderedStepItem): string {
const ts = new Date(item.timestamp)
.toISOString()
.replace("T", " ")
.replace(/\.\d+Z$/, "");
return [
`## Step ${stepNum}: ${item.payload.role} \`${item.hash}\``,
`**Agent:** ${item.payload.agent} | **Time:** ${ts}`,
].join("\n");
}
function formatStepPrompt(
roleDef: WorkflowPayload["roles"][string] | undefined,
role: string,
shownPromptRoles: Set<string>,
): string {
if (!roleDef || shownPromptRoles.has(role)) return "";
shownPromptRoles.add(role);
return ["", "", "<prompt>", roleDef.goal, "</prompt>"].join("\n");
}
function formatStepContent(uwf: UwfStore, item: OrderedStepItem): string {
if (!item.payload.detail) return "";
const content = extractLastAssistantContent(uwf, item.payload.detail);
if (content === null) return "";
return ["", "", "<output>", content, "</output>"].join("\n");
}
function formatStartSection(options: {
threadId: ThreadId;
workflowName: string;
workflowHash: CasRef;
prompt: string;
before: CasRef | null;
showStart: boolean;
}): string {
if (options.before !== null && !options.showStart) return "";
return [
`# Thread \`${options.threadId}\``,
"",
`**Workflow:** ${options.workflowName} (\`${options.workflowHash}\`)`,
"",
"## Task",
"",
options.prompt,
].join("\n");
}
function formatThreadReadMarkdown(options: {
threadId: ThreadId;
workflowName: string;
@@ -600,50 +621,26 @@ function formatThreadReadMarkdown(options: {
before: CasRef | null;
showStart: boolean;
}): string {
const { ordered, uwf, workflow, quota, before, showStart } = options;
const { ordered, uwf, workflow, quota, before } = options;
// Determine which steps to consider
let candidates = ordered;
if (before !== null) {
const idx = candidates.findIndex((s) => s.hash === before);
if (idx === -1) {
fail(`step ${before} not found in thread ${options.threadId}`);
}
candidates = candidates.slice(0, idx);
}
const candidates = before !== null ? sliceBeforeHash(ordered, before, options.threadId) : ordered;
// Walk backward from newest, accumulating chars until quota exceeded
const selected: OrderedStepItem[] = [];
let totalChars = 0;
for (let i = candidates.length - 1; i >= 0; i--) {
const item = candidates[i];
if (item === undefined) continue;
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
const blockLen = formatCompactStep(i + 1, item, outputYaml).length;
selected.unshift(item);
totalChars += blockLen;
if (totalChars > quota) break;
}
// Calculate start section length for quota accounting
const startSection = formatStartSection(options);
const startSectionLength = startSection !== "" ? startSection.length : 0;
const { selected, skippedCount } = selectByQuota(
candidates,
uwf,
workflow,
quota,
startSectionLength,
);
const skippedCount = candidates.length - selected.length;
const parts: string[] = [];
// Start section
if (before === null || showStart) {
parts.push(
[
`# Thread \`${options.threadId}\``,
"",
`**Workflow:** ${options.workflowName} (\`${options.workflowHash}\`)`,
"",
"## Task",
"",
options.prompt,
].join("\n"),
);
}
if (startSection !== "") parts.push(startSection);
// Skip hint
if (skippedCount > 0 && selected.length > 0) {
const firstSelected = selected[0];
if (firstSelected !== undefined) {
@@ -653,34 +650,21 @@ function formatThreadReadMarkdown(options: {
}
}
// Step blocks
const startIndex = candidates.length - selected.length;
const shownPromptRoles = new Set<string>();
for (let i = 0; i < selected.length; i++) {
const item = selected[i];
if (item === undefined) continue;
const stepNum = startIndex + i + 1;
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
const ts = new Date(item.timestamp)
.toISOString()
.replace("T", " ")
.replace(/\.\d+Z$/, "");
const stepLines = [
`## Step ${stepNum}: ${item.payload.role} \`${item.hash}\``,
`**Agent:** ${item.payload.agent} | **Time:** ${ts}`,
];
const roleDef = workflow.roles[item.payload.role];
if (roleDef) {
const prompt = roleDef.goal;
stepLines.push("", "### Prompt", "", prompt);
}
if (item.payload.detail) {
const content = extractLastAssistantContent(uwf, item.payload.detail);
if (content !== null) {
stepLines.push("", "### Content", "", content);
}
}
stepLines.push("", "### Output", "", "```yaml", outputYaml, "```");
parts.push(stepLines.join("\n"));
const stepBlock = [
formatStepHeader(stepNum, item),
formatStepPrompt(roleDef, item.payload.role, shownPromptRoles),
formatStepContent(uwf, item),
]
.filter((s) => s !== "")
.join("");
parts.push(stepBlock);
}
return parts.join("\n\n---\n\n");
@@ -694,6 +678,7 @@ function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorConte
detail: step.detail,
agent: step.agent,
edgePrompt: step.edgePrompt ?? "",
content: null, // Moderator doesn't need content
}));
return { start: chain.start, steps };
}
@@ -753,13 +738,11 @@ function spawnAgent(
role: string,
edgePrompt: string,
): CasRef {
const argv = [...agent.args, threadId, role];
const env = { ...process.env, UWF_EDGE_PROMPT: edgePrompt };
const argv = [...agent.args, "--thread", threadId, "--role", role, "--prompt", edgePrompt];
let stdout: string;
try {
stdout = execFileSync(agent.command, argv, {
encoding: "utf8",
env,
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 50 * 1024 * 1024, // 50 MB — stream-json output can be large
});
@@ -799,31 +782,65 @@ async function archiveThread(
});
}
export async function cmdThreadStep(
export async function cmdThreadExec(
storageRoot: string,
threadId: ThreadId,
agentOverride: string | null,
count: number,
background: boolean,
backgroundWorker: boolean,
): Promise<StepOutput[]> {
if (count < 1 || !Number.isInteger(count)) {
fail(`--count must be a positive integer, got: ${count}`);
}
// Check if thread is already running in background (unless we ARE the background worker)
if (!backgroundWorker) {
const runningMarker = await isThreadRunning(storageRoot, threadId);
if (runningMarker !== null) {
fail(`thread already executing in background (PID: ${runningMarker.pid})`);
}
}
const workflowHash = await resolveActiveThreadWorkflowHash(storageRoot, threadId);
const plog = createProcessLogger({
storageRoot,
context: { thread: threadId, workflow: workflowHash },
});
const results: StepOutput[] = [];
for (let i = 0; i < count; i++) {
const result = await cmdThreadStepOnce(storageRoot, threadId, agentOverride, plog);
results.push(result);
if (result.done) {
break;
if (background && !backgroundWorker) {
// Spawn background process
return cmdThreadStepBackground(storageRoot, threadId, agentOverride, count, plog, workflowHash);
}
// If we're the background worker, create marker before execution
let markerCreated = false;
if (backgroundWorker) {
await createMarker(storageRoot, {
thread: threadId,
workflow: workflowHash,
pid: process.pid,
startedAt: Date.now(),
});
markerCreated = true;
}
try {
const results: StepOutput[] = [];
for (let i = 0; i < count; i++) {
const result = await cmdThreadStepOnce(storageRoot, threadId, agentOverride, plog);
results.push(result);
if (result.done) {
break;
}
}
return results;
} finally {
// Cleanup marker if we created one
if (markerCreated) {
await deleteMarker(storageRoot, threadId);
}
}
return results;
}
async function resolveActiveThreadWorkflowHash(
@@ -840,6 +857,57 @@ async function resolveActiveThreadWorkflowHash(
return chain.start.workflow;
}
async function cmdThreadStepBackground(
storageRoot: string,
threadId: ThreadId,
agentOverride: string | null,
count: number,
plog: ProcessLogger,
workflowHash: CasRef,
): Promise<StepOutput[]> {
// Get current head to return to caller
const index = await loadThreadsIndex(storageRoot);
const headHash = index[threadId];
if (headHash === undefined) {
failStep(plog, `thread not active: ${threadId}`);
}
// Spawn detached background process
const scriptPath = process.argv[1];
if (scriptPath === undefined) {
failStep(plog, "unable to determine script path for background execution");
}
const args = ["thread", "exec", threadId, "--count", String(count)];
if (agentOverride !== null) {
args.push("--agent", agentOverride);
}
// Internal flag to signal the background worker to create/cleanup markers
args.push("--_background-worker");
plog.log(PL_BACKGROUND_START, `spawning background process count=${count}`, null);
const child = spawn(scriptPath, args, {
detached: true,
stdio: "ignore",
});
child.unref();
// Return immediately with current state and background flag
return [
{
workflow: workflowHash,
thread: threadId,
head: headHash,
done: false,
background: true,
},
];
}
async function cmdThreadStepOnce(
storageRoot: string,
threadId: ThreadId,
@@ -877,6 +945,7 @@ async function cmdThreadStepOnce(
thread: threadId,
head: headHash,
done: true,
background: null,
};
}
@@ -924,6 +993,7 @@ async function cmdThreadStepOnce(
thread: threadId,
head: newHead,
done,
background: null,
};
}
@@ -940,47 +1010,6 @@ async function resolveHeadHash(storageRoot: string, threadId: ThreadId): Promise
fail(`thread not found: ${threadId}`);
}
export async function cmdThreadSteps(
storageRoot: string,
threadId: ThreadId,
): Promise<ThreadStepsOutput> {
const headHash = await resolveHeadHash(storageRoot, threadId);
const uwf = await createUwfStore(storageRoot);
const chain = walkChain(uwf, headHash);
const startNode = uwf.store.get(chain.startHash);
if (startNode === null) {
fail(`StartNode not found: ${chain.startHash}`);
}
const startEntry: StartEntry = {
hash: chain.startHash,
workflow: chain.start.workflow,
prompt: chain.start.prompt,
timestamp: startNode.timestamp,
};
const stepEntries: StepEntry[] = [];
const ordered = collectOrderedSteps(uwf, headHash, chain);
for (const item of ordered) {
stepEntries.push({
hash: item.hash,
role: item.payload.role,
output: expandOutput(uwf, item.payload.output),
detail: item.payload.detail,
agent: item.payload.agent,
timestamp: item.timestamp,
});
}
return {
thread: threadId,
workflow: chain.start.workflow,
steps: [startEntry, ...stepEntries],
};
}
export async function cmdThreadRead(
storageRoot: string,
threadId: ThreadId,
@@ -1008,58 +1037,67 @@ export async function cmdThreadRead(
});
}
export async function cmdThreadFork(
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`);
}
export type StopOutput = {
thread: ThreadId;
stopped: boolean;
};
const newThreadId = generateUlid(Date.now()) as ThreadId;
const index = await loadThreadsIndex(storageRoot);
index[newThreadId] = stepHash;
await saveThreadsIndex(storageRoot, index);
export type CancelOutput = {
thread: ThreadId;
cancelled: boolean;
};
return {
thread: newThreadId,
forkedFrom: {
step: stepHash,
},
};
}
export async function cmdThreadStepDetails(
storageRoot: string,
stepHash: CasRef,
): Promise<unknown> {
const uwf = await createUwfStore(storageRoot);
const node = uwf.store.get(stepHash);
if (node === null) {
fail(`CAS node not found: ${stepHash}`);
}
if (node.type !== uwf.schemas.stepNode) {
fail(`node ${stepHash} is not a StepNode`);
}
const payload = node.payload as StepNodePayload;
if (!payload.detail) {
fail(`step ${stepHash} has no detail`);
}
return expandDeep(uwf.store, payload.detail);
}
export async function cmdThreadKill(storageRoot: string, threadId: ThreadId): Promise<KillOutput> {
/**
* Stop background execution of a thread (but keep thread active)
*/
export async function cmdThreadStop(storageRoot: string, threadId: ThreadId): Promise<StopOutput> {
const index = await loadThreadsIndex(storageRoot);
const head = index[threadId];
if (head === undefined) {
fail(`thread not active: ${threadId}`);
}
// Check if thread is running in background and terminate it
const runningMarker = await isThreadRunning(storageRoot, threadId);
if (runningMarker === null) {
process.stderr.write(`Warning: thread ${threadId} is not currently running\n`);
return { thread: threadId, stopped: false };
}
try {
process.kill(runningMarker.pid, "SIGTERM");
} catch {
// Process may have already exited, ignore error
}
await deleteMarker(storageRoot, threadId);
return { thread: threadId, stopped: true };
}
/**
* Cancel a thread (stop execution + move to history)
*/
export async function cmdThreadCancel(
storageRoot: string,
threadId: ThreadId,
): Promise<CancelOutput> {
const index = await loadThreadsIndex(storageRoot);
const head = index[threadId];
if (head === undefined) {
fail(`thread not active: ${threadId}`);
}
// Check if thread is running in background and terminate it
const runningMarker = await isThreadRunning(storageRoot, threadId);
if (runningMarker !== null) {
try {
process.kill(runningMarker.pid, "SIGTERM");
} catch {
// Process may have already exited, ignore error
}
await deleteMarker(storageRoot, threadId);
}
const uwf = await createUwfStore(storageRoot);
const workflow = resolveWorkflowFromHead(uwf, head);
if (workflow === null) {
@@ -1077,5 +1115,5 @@ export async function cmdThreadKill(storageRoot: string, threadId: ThreadId): Pr
};
await appendThreadHistory(storageRoot, historyEntry);
return { thread: threadId, archived: true };
return { thread: threadId, cancelled: true };
}
@@ -29,7 +29,7 @@ export type WorkflowListEntry = {
origin: WorkflowOrigin;
};
export type WorkflowPutOutput = {
export type WorkflowAddOutput = {
name: string;
hash: CasRef;
};
@@ -111,10 +111,10 @@ export async function materializeWorkflowPayload(
};
}
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");
+141
View File
@@ -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("");
});
});
+24 -6
View File
@@ -13,10 +13,28 @@ 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 { buildBuiltinMessages } from "./prompt.js";
import { initSessionDir, removeSession } from "./session.js";
import { initSessionDir } from "./session.js";
const log = createLogger({ sink: { kind: "stderr" } });
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;
@@ -48,6 +66,7 @@ async function runBuiltinWithMessages(
session: SessionRecord,
store: Store,
maxTurns: number,
noTools: boolean,
): Promise<AgentRunResult> {
const loopResult = await runBuiltinLoop({
provider,
@@ -56,13 +75,13 @@ async function runBuiltinWithMessages(
maxTurns,
storageRoot,
sessionId: session.sessionId,
noTools,
});
session.messages = loopResult.messages;
if (loopResult.turnCount === 0) {
log("5RWTK9NB", "no turns produced, returning empty output");
await removeSession(storageRoot, session.sessionId);
return { output: "", detailHash: "", sessionId: session.sessionId };
}
@@ -75,10 +94,7 @@ async function runBuiltinWithMessages(
session.startedAtMs,
);
// Clean up session jsonl
await removeSession(storageRoot, session.sessionId);
return { output: loopResult.finalText, detailHash, sessionId: session.sessionId };
return { output: stripPreamble(loopResult.finalText), detailHash, sessionId: session.sessionId };
}
async function runBuiltin(ctx: AgentContext): Promise<AgentRunResult> {
@@ -105,6 +121,7 @@ async function runBuiltin(ctx: AgentContext): Promise<AgentRunResult> {
session,
ctx.store,
BUILTIN_MAX_TURNS,
false,
);
}
@@ -127,6 +144,7 @@ async function continueBuiltin(
session,
store,
BUILTIN_CONTINUE_MAX_TURNS,
true,
);
}
+11 -7
View File
@@ -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);
+215 -50
View File
@@ -1,7 +1,12 @@
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,
@@ -23,6 +28,8 @@ export type RunBuiltinLoopOptions = {
maxTurns: number;
storageRoot: string;
sessionId: string;
/** When true, do not provide tools — force LLM to emit text only. */
noTools: boolean;
};
export type RunBuiltinLoopResult = {
@@ -46,7 +53,7 @@ async function appendTurn(
await appendSessionTurn(storageRoot, sessionId, payload);
}
async function executeTurnTools(
export async function executeTurnTools(
calls: Array<{ id: string; name: string; arguments: string }>,
toolCtx: ToolContext,
messages: ChatMessage[],
@@ -68,70 +75,228 @@ async function executeTurnTools(
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 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 ?? "";
await appendTurn(options.storageRoot, options.sessionId, {
role: "assistant",
content: response.content ?? "",
toolCalls: null,
reasoning: null,
});
turnCount += 1;
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;
}
// Assistant turn with tool calls
await appendTurn(options.storageRoot, options.sessionId, {
role: "assistant",
content: response.content ?? "",
toolCalls: mapToolCallsForPayload(response.toolCalls),
reasoning: null,
});
turnCount += 1;
// Execute tools
turnCount += await executeTurnTools(
response.toolCalls,
options.toolCtx,
messages,
options.storageRoot,
options.sessionId,
);
}
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, turnCount };
@@ -59,6 +59,22 @@ export function buildBuiltinMessages(ctx: AgentContext): ChatMessage[] {
}
systemParts.push(rolePrompt);
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[] = [];
@@ -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,7 +39,7 @@ 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: [
{
@@ -48,18 +48,50 @@ describe("buildClaudeCodePrompt", () => {
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", () => {
@@ -154,6 +154,99 @@ describe("parseClaudeCodeStreamOutput", () => {
});
});
describe("parseClaudeCodeStreamOutput — helper extraction", () => {
test("processSystemLine sets model from system message", () => {
const lines = [
JSON.stringify({ type: "system", model: "claude-opus-4" }),
JSON.stringify({
type: "result",
subtype: "success",
result: "ok",
session_id: "s1",
num_turns: 0,
total_cost_usd: 0,
duration_ms: 0,
stop_reason: "end_turn",
}),
];
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
expect(parsed).not.toBeNull();
expect(parsed!.model).toBe("claude-opus-4");
});
test("processAssistantLine skips empty content", () => {
const lines = [
JSON.stringify({ type: "assistant", message: { role: "assistant", content: [] } }),
JSON.stringify({
type: "result",
subtype: "success",
result: "ok",
session_id: "s1",
num_turns: 0,
total_cost_usd: 0,
duration_ms: 0,
stop_reason: "end_turn",
}),
];
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
expect(parsed).not.toBeNull();
expect(parsed!.turns).toHaveLength(0);
});
test("processUserLine skips when no tool_result items", () => {
const lines = [
JSON.stringify({
type: "user",
message: { role: "user", content: [{ type: "text", text: "hi" }] },
}),
JSON.stringify({
type: "result",
subtype: "success",
result: "ok",
session_id: "s1",
num_turns: 0,
total_cost_usd: 0,
duration_ms: 0,
stop_reason: "end_turn",
}),
];
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
expect(parsed).not.toBeNull();
expect(parsed!.turns).toHaveLength(0);
});
test("turn indices are sequential across mixed assistant and user lines", () => {
const lines = [
JSON.stringify({
type: "assistant",
message: { role: "assistant", content: [{ type: "text", text: "A" }] },
}),
JSON.stringify({
type: "user",
message: { role: "user", content: [{ type: "tool_result", content: "R" }] },
}),
JSON.stringify({
type: "assistant",
message: { role: "assistant", content: [{ type: "text", text: "B" }] },
}),
JSON.stringify({
type: "result",
subtype: "success",
result: "ok",
session_id: "s1",
num_turns: 3,
total_cost_usd: 0,
duration_ms: 0,
stop_reason: "end_turn",
}),
];
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
expect(parsed).not.toBeNull();
expect(parsed!.turns).toHaveLength(3);
expect(parsed!.turns.map((t) => t.index)).toEqual([0, 1, 2]);
});
});
describe("storeClaudeCodeDetail", () => {
const baseParsed: ClaudeCodeParsedResult = {
type: "result",
@@ -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"
@@ -3,6 +3,7 @@ import type { Store } from "@uncaged/json-cas";
import {
type AgentContext,
type AgentRunResult,
buildContinuationPrompt,
buildRolePrompt,
createAgent,
getCachedSessionId,
@@ -16,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 {
@@ -45,11 +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);
}
parts.push("", "## Current Instruction", "", ctx.edgePrompt);
return parts.join("\n");
}
@@ -87,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",
@@ -96,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",
@@ -114,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> {
@@ -137,13 +140,13 @@ async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
// 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) {
@@ -160,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.
+90
View File
@@ -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;
@@ -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",
@@ -55,6 +55,7 @@ describe("buildHermesPrompt", () => {
agent: "uwf-hermes",
detail: "detail-1",
edgePrompt: "Implement the fix.",
content: null,
},
{
role: "reviewer",
@@ -62,6 +63,7 @@ describe("buildHermesPrompt", () => {
agent: "uwf-hermes",
detail: "detail-2",
edgePrompt: "Review the code.",
content: null,
},
],
});
@@ -85,6 +87,7 @@ describe("buildHermesPrompt", () => {
agent: "uwf-hermes",
detail: "detail-1",
edgePrompt: "First attempt.",
content: null,
},
],
edgePrompt: "Retry with a fresh approach.",
@@ -95,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!");
});
});
@@ -245,72 +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("");
+23 -37
View File
@@ -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
+183
View File
@@ -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`.
@@ -8,6 +8,7 @@ const reviewerStep: StepContext = {
detail: "2MXBG6PN4A8JR",
agent: "uwf-hermes",
edgePrompt: "Review the developer's work.",
content: null,
};
const developerStep: StepContext = {
@@ -16,6 +17,7 @@ const developerStep: StepContext = {
detail: "1VPBG9SM5E7WK",
agent: "uwf-hermes",
edgePrompt: "Implement the fix.",
content: null,
};
describe("buildContinuationPrompt", () => {
@@ -29,6 +31,7 @@ describe("buildContinuationPrompt", () => {
detail: "7BQST3VW9F2MA",
agent: "uwf-hermes",
edgePrompt: "Revise the plan.",
content: null,
},
];
@@ -70,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("");
}
+40 -11
View File
@@ -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,12 +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;
@@ -123,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;
@@ -142,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 {
@@ -172,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);
@@ -191,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 {
+1 -1
View File
@@ -12,7 +12,7 @@ export {
export type { FrontmatterFastPathResult } from "./frontmatter.js";
export { tryFrontmatterFastPath } from "./frontmatter.js";
export { createAgent } from "./run.js";
export { getCachedSessionId, setCachedSessionId } from "./session-cache.js";
export { getCachedSessionId, getCachePath, setCachedSessionId } from "./session-cache.js";
export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
export type {
AgentContext,
+25 -12
View File
@@ -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> {
@@ -103,11 +111,11 @@ async function persistStep(options: {
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) {
@@ -121,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);
@@ -147,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),
});
@@ -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);
}
+1 -1
View File
@@ -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;
+84
View File
@@ -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.
+3 -1
View File
@@ -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"
}
}
@@ -0,0 +1,83 @@
import type { Edge, Node } from "@xyflow/react";
import { describe, expect, it } from "vitest";
import { LayoutLR } from "../index.js";
function makeNode(id: string): Node {
return { id, type: "role", data: {}, position: { x: 0, y: 0 } } as Node;
}
function makeEdge(source: string, target: string): Edge {
return { id: `${source}-${target}`, source, target } as Edge;
}
describe("LayoutLR / assignLayers", () => {
it("1.1 Empty graph: start gets layer 0, end gets higher layer", () => {
const nodes = [makeNode("start"), makeNode("end")];
const result = LayoutLR(nodes, []);
const start = result.find((n) => n.id === "start");
const end = result.find((n) => n.id === "end");
// start has no position change necessarily, but positions should be assigned
expect(start).toBeDefined();
expect(end).toBeDefined();
// end should be to the right of start
expect((end?.position.x ?? 0) > (start?.position.x ?? 0)).toBe(true);
});
it("1.2 Linear chain: start → A → B → end — layers assigned in order", () => {
const nodes = [makeNode("start"), makeNode("A"), makeNode("B"), makeNode("end")];
const edges = [makeEdge("start", "A"), makeEdge("A", "B"), makeEdge("B", "end")];
const result = LayoutLR(nodes, edges);
const xOf = (id: string) => result.find((n) => n.id === id)?.position.x ?? 0;
expect(xOf("start") < xOf("A")).toBe(true);
expect(xOf("A") < xOf("B")).toBe(true);
expect(xOf("B") < xOf("end")).toBe(true);
});
it("1.3 Diamond: A and B share same layer", () => {
const nodes = [makeNode("start"), makeNode("A"), makeNode("B"), makeNode("C"), makeNode("end")];
const edges = [
makeEdge("start", "A"),
makeEdge("start", "B"),
makeEdge("A", "C"),
makeEdge("B", "C"),
makeEdge("C", "end"),
];
const result = LayoutLR(nodes, edges);
const xOf = (id: string) => result.find((n) => n.id === id)?.position.x ?? 0;
expect(xOf("A")).toBe(xOf("B")); // same layer
expect(xOf("A") < xOf("C")).toBe(true);
expect(xOf("C") < xOf("end")).toBe(true);
});
it("1.4 Isolated node placed in middle layer (not layer 0, not end layer)", () => {
const nodes = [makeNode("start"), makeNode("A"), makeNode("isolated"), makeNode("end")];
const edges = [makeEdge("start", "A"), makeEdge("A", "end")];
const result = LayoutLR(nodes, edges);
const xOf = (id: string) => result.find((n) => n.id === id)?.position.x ?? 0;
const xIsolated = xOf("isolated");
expect(xIsolated > xOf("start")).toBe(true);
expect(xIsolated < xOf("end")).toBe(true);
});
it("1.5 end node is always last (highest x)", () => {
const nodes = [makeNode("start"), makeNode("A"), makeNode("B"), makeNode("end")];
const edges = [makeEdge("start", "A"), makeEdge("A", "B"), makeEdge("B", "end")];
const result = LayoutLR(nodes, edges);
const endX = result.find((n) => n.id === "end")?.position.x ?? 0;
for (const node of result) {
if (node.id !== "end") {
expect(node.position.x < endX).toBe(true);
}
}
});
it("1.6 start node is always first (x = 0 or smallest x)", () => {
const nodes = [makeNode("start"), makeNode("A"), makeNode("end")];
const edges = [makeEdge("start", "A"), makeEdge("A", "end")];
const result = LayoutLR(nodes, edges);
const startX = result.find((n) => n.id === "start")?.position.x ?? 0;
for (const node of result) {
expect(node.position.x >= startX).toBe(true);
}
});
});
@@ -43,6 +43,65 @@ function buildGraph(nodes: Node[], edges: Edge[]) {
return { outgoing, incoming, inDegree };
}
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");
bfsLayers(outgoing, inDegree, layers);
// 2. BFS 分层(排除 end 节点,稍后单独处理)
while (queue.length > 0) {
const current = queue.shift() ?? "";
const currentLayer = layers.get(current) ?? 0;
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;
}
@@ -30,23 +30,24 @@ export const handlers = define.memoize((use, model) => {
});
};
function isProtectedNode(node: AnyWorkNode): boolean {
return node.type === "start" || node.type === "end";
}
function isFirstConditionalSibling(
edge: { id: string; source: string; type: string | null },
allEdges: { id: string; source: string; type: string | null }[],
): boolean {
if (edge.type !== "conditional") return false;
const siblings = allEdges.filter((e) => e.source === edge.source && e.type === "conditional");
return siblings.length >= 2 && siblings[0].id === edge.id;
}
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes, edges }) => {
for (const node of nodes) {
if (node.type === "start" || node.type === "end") {
return false;
}
}
if (nodes.some(isProtectedNode)) return false;
if (edges.length > 0) {
const allEdges = use(edgesModel)[0];
for (const edge of edges) {
if (edge.type !== "conditional") continue;
const siblings = allEdges.filter(
(e) => e.source === edge.source && e.type === "conditional",
);
if (siblings.length >= 2 && siblings[0].id === edge.id) {
return false;
}
}
if (edges.some((e) => isFirstConditionalSibling(e, allEdges))) return false;
}
model.startTransaction();
return true;
@@ -96,25 +97,28 @@ export const handlers = define.memoize((use, model) => {
use(editNodeViewModel)[1].cancel();
}
function handleEscape() {
const [addView, addViewActions] = use(addNodeViewModel);
const [editView, editViewActions] = use(editNodeViewModel);
if (addView) addViewActions.cancel();
if (editView) editViewActions.cancel();
}
function handleUndoRedo(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.code === "KeyZ" && (event.ctrlKey || event.metaKey)) {
if (event.shiftKey) model.redo();
else model.undo();
} else if (event.code === "KeyY" && (event.ctrlKey || event.metaKey)) {
model.redo();
}
}
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.code === "Escape") {
const [addView, addViewActions] = use(addNodeViewModel);
const [editView, editViewActions] = use(editNodeViewModel);
if (addView) addViewActions.cancel();
if (editView) editViewActions.cancel();
handleEscape();
return;
}
if (event.code === "KeyZ") {
if (event.ctrlKey || event.metaKey) {
if (event.shiftKey) model.redo();
else model.undo();
}
} else if (event.code === "KeyY") {
if (event.ctrlKey || event.metaKey) {
model.redo();
}
}
handleUndoRedo(event);
}
function loadSteps(steps: WorkFlowSteps) {
@@ -10,16 +10,15 @@ import {
import { Input } from "../../components/ui/input.tsx";
import { Label } from "../../components/ui/label.tsx";
import { Textarea } from "../../components/ui/textarea.tsx";
import { type AddNodeState, addNodeViewModel } from "../model/index.ts";
import { addNodeViewModel } from "../model/index.ts";
import type { RoleNodeData } from "../type.ts";
type FormProps = {
state: AddNodeState;
onSubmit: (params: { data: RoleNodeData }) => void;
onCancel: () => void;
};
function Form({ state, onSubmit, onCancel }: FormProps): ReactNode {
function Form({ onSubmit, onCancel }: FormProps): ReactNode {
const [name, setName] = useState("新角色");
const [description, setDescription] = useState("");
const [identity, setIdentity] = useState("");
@@ -137,7 +136,7 @@ export function AddNodeDialog(): ReactNode {
}}
>
<DialogContent showCloseButton={false} className="sm:max-w-md">
{state && <Form state={state} onSubmit={commit} onCancel={cancel} />}
{state && <Form onSubmit={commit} onCancel={cancel} />}
</DialogContent>
</Dialog>
);
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { transIn } from "../trans-in.js";
import type { WorkFlowStep } from "../type.js";
function makeStep(name: string, transitions: WorkFlowStep["transitions"]): WorkFlowStep {
return {
role: {
name,
description: "",
identity: "",
prepare: "",
execute: "",
report: "",
},
transitions,
};
}
describe("transIn", () => {
it("4.1 Empty steps → start + end nodes, no edges", () => {
const { nodes, edges } = transIn([]);
expect(nodes).toHaveLength(2);
expect(nodes.find((n) => n.id === "start")).toBeDefined();
expect(nodes.find((n) => n.id === "end")).toBeDefined();
expect(edges).toHaveLength(0);
});
it("4.2 Single step with no END transition → start→role edge exists", () => {
const steps = [makeStep("A", [])];
const { nodes, edges } = transIn(steps);
expect(nodes).toHaveLength(3); // start, end, role-A
const startEdge = edges.find((e) => e.source === "start");
expect(startEdge).toBeDefined();
const roleNode = nodes.find((n) => n.type === "role");
expect(startEdge?.target).toBe(roleNode?.id);
});
it("4.3 Single step with END transition → edge to end node exists", () => {
const steps = [makeStep("A", [{ condition: null, target: "END" }])];
const { edges } = transIn(steps);
const endEdge = edges.find((e) => e.target === "end");
expect(endEdge).toBeDefined();
});
it("4.4 Two steps with default transitions chain", () => {
const steps = [
makeStep("A", [{ condition: null, target: "B" }]),
makeStep("B", [{ condition: null, target: "END" }]),
];
const { edges } = transIn(steps);
// Should have start→A, A→B, B→end
expect(edges.find((e) => e.source === "start")).toBeDefined();
const nodeAId = edges.find((e) => e.source === "start")?.target;
expect(edges.find((e) => e.source === nodeAId && e.target !== "end")).toBeDefined();
expect(edges.find((e) => e.target === "end")).toBeDefined();
// No conditional edges
expect(edges.every((e) => e.type !== "conditional")).toBe(true);
});
it("4.5 Step with multiple transitions → conditional edges", () => {
const steps = [
makeStep("A", [
{ condition: null, target: "B" },
{ condition: "x>0", target: "C" },
]),
makeStep("B", []),
makeStep("C", []),
];
const { edges } = transIn(steps);
const nodeAId = edges.find((e) => e.source === "start")?.target;
const outEdges = edges.filter((e) => e.source === nodeAId);
expect(outEdges.every((e) => e.type === "conditional")).toBe(true);
// else-branch has empty condition
const elseEdge = outEdges.find(
(e) => (e as { data?: { condition?: string } }).data?.condition === "",
);
expect(elseEdge).toBeDefined();
// if-branch has condition
const ifEdge = outEdges.find(
(e) => (e as { data?: { condition?: string } }).data?.condition === "x>0",
);
expect(ifEdge).toBeDefined();
});
it("4.6 With 1 incoming edge: targetHandle = 'input'; with 2: first gets 'input'", () => {
const steps = [
makeStep("A", [{ condition: null, target: "END" }]),
makeStep("B", [{ condition: null, target: "END" }]),
];
const { edges } = transIn(steps);
// start→A and start→B; end has 2 incoming edges
const incomingToEnd = edges.filter((e) => e.target === "end");
expect(incomingToEnd[0].targetHandle).toBe("input");
});
it("4.7 Same role name maps to same node id across steps", () => {
const steps = [
makeStep("A", [{ condition: null, target: "B" }]),
makeStep("B", [{ condition: null, target: "A" }]),
];
const { edges } = transIn(steps);
const aId = edges.find((e) => e.source === "start")?.target;
// B→A edge target should be same node as start→A edge target
const bToAEdge = edges.find(
(e) => e.source !== "start" && e.target === aId && e.target !== "end",
);
expect(bToAEdge).toBeDefined();
});
});
@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import type { AnyWorkEdge, AnyWorkNode } from "../../type.js";
import { validate } from "../validate.js";
function roleNode(id: string): AnyWorkNode {
return {
id,
type: "role",
data: { name: id, description: "", identity: "", prepare: "", execute: "", report: "" },
position: { x: 0, y: 0 },
} as AnyWorkNode;
}
function startNode(): AnyWorkNode {
return {
id: "start",
type: "start",
data: { label: "Start" },
position: { x: 0, y: 0 },
} as AnyWorkNode;
}
function endNode(): AnyWorkNode {
return {
id: "end",
type: "end",
data: { label: "End" },
position: { x: 0, y: 0 },
} as AnyWorkNode;
}
function defaultEdge(source: string, target: string): AnyWorkEdge {
return { id: `${source}-${target}`, source, target, animated: true } as AnyWorkEdge;
}
function conditionalEdge(source: string, target: string, condition: string): AnyWorkEdge {
return {
id: `${source}-${target}-cond`,
source,
target,
type: "conditional" as const,
data: { condition },
animated: true,
} as AnyWorkEdge;
}
// Helper: build a minimal valid graph with 2 role nodes for validateRoleNodes tests
function baseNodes(...roles: AnyWorkNode[]): AnyWorkNode[] {
return [startNode(), ...roles, endNode()];
}
describe("validateRoleNodes (via validate)", () => {
it("5.1 Role node with no incoming edge → error about missing input", () => {
const n1 = roleNode("n1");
const n2 = roleNode("n2");
const nodes = baseNodes(n1, n2);
// n1 has no incoming, n2 has incoming+outgoing
const edges = [defaultEdge("start", "n2"), defaultEdge("n1", "end"), defaultEdge("n2", "end")];
const result = validate(nodes, edges);
const nodeErrors = result.errors.filter((e) => e.nodeId === "n1");
expect(nodeErrors.some((e) => e.message.includes("缺少输入连接"))).toBe(true);
});
it("5.2 Role node with no outgoing edge → error about missing output", () => {
const n1 = roleNode("n1");
const n2 = roleNode("n2");
const nodes = baseNodes(n1, n2);
const edges = [
defaultEdge("start", "n1"),
defaultEdge("start", "n2"),
defaultEdge("n2", "end"),
// n1 has no outgoing
];
const result = validate(nodes, edges);
const nodeErrors = result.errors.filter((e) => e.nodeId === "n1");
expect(nodeErrors.some((e) => e.message.includes("缺少输出连接"))).toBe(true);
});
it("5.3 Empty condition on non-first conditional edge → error", () => {
const n1 = roleNode("n1");
const n2 = roleNode("n2");
const n3 = roleNode("n3");
const nodes = baseNodes(n1, n2, n3);
const edges = [
defaultEdge("start", "n1"),
conditionalEdge("n1", "n2", ""), // else-branch (index 0) - exempt
conditionalEdge("n1", "n3", ""), // if-branch (index 1) - empty condition → error
defaultEdge("n2", "end"),
defaultEdge("n3", "end"),
];
const result = validate(nodes, edges);
expect(result.errors.some((e) => e.message.includes("条件表达式不能为空"))).toBe(true);
});
it("5.4 Mix of conditional and non-conditional outgoing → error", () => {
const n1 = roleNode("n1");
const n2 = roleNode("n2");
const n3 = roleNode("n3");
const nodes = baseNodes(n1, n2, n3);
const edges = [
defaultEdge("start", "n1"),
conditionalEdge("n1", "n2", "x>0"),
defaultEdge("n1", "n3"), // mix → error
defaultEdge("n2", "end"),
defaultEdge("n3", "end"),
];
const result = validate(nodes, edges);
expect(result.errors.some((e) => e.message.includes("所有出边必须附带条件"))).toBe(true);
});
it("5.5 Valid role node (1 in, 1 out default) → no errors for that node", () => {
const n1 = roleNode("n1");
const n2 = roleNode("n2");
const nodes = baseNodes(n1, n2);
const edges = [defaultEdge("start", "n1"), defaultEdge("n1", "n2"), defaultEdge("n2", "end")];
const result = validate(nodes, edges);
const roleErrors = result.errors.filter((e) => e.nodeId === "n1" || e.nodeId === "n2");
expect(roleErrors).toHaveLength(0);
});
it("5.6 Valid role node (1 in, 2 conditional out with conditions) → no errors", () => {
const n1 = roleNode("n1");
const n2 = roleNode("n2");
const n3 = roleNode("n3");
const nodes = baseNodes(n1, n2, n3);
const edges = [
defaultEdge("start", "n1"),
conditionalEdge("n1", "n2", ""), // else-branch
conditionalEdge("n1", "n3", "x>0"), // if-branch
defaultEdge("n2", "end"),
defaultEdge("n3", "end"),
];
const result = validate(nodes, edges);
const n1Errors = result.errors.filter((e) => e.nodeId === "n1");
expect(n1Errors).toHaveLength(0);
});
});
@@ -28,6 +28,109 @@ function assignHandles(
}
}
function buildNodeMap(
steps: WorkFlowStep[],
nodes: AnyWorkNode[],
): { nameToId: Map<string, string>; idToOrder: Map<string, number> } {
const nameToId = new Map<string, string>();
const idToOrder = new Map<string, number>();
nameToId.set("END", "end");
idToOrder.set("start", -1);
idToOrder.set("end", steps.length);
for (let si = 0; si < steps.length; si++) {
const step = steps[si];
const nodeId = `n${uuid()}`;
nameToId.set(step.role.name, nodeId);
idToOrder.set(nodeId, si);
nodes.push({ id: nodeId, type: "role", data: { ...step.role }, position: { x: 0, y: 0 } });
}
return { nameToId, idToOrder };
}
function sortTransitions(step: WorkFlowStep): WorkFlowStep["transitions"] {
if (step.transitions.length <= 1) return step.transitions;
return [...step.transitions].sort((a, b) => {
if (a.condition === null && b.condition !== null) return -1;
if (a.condition !== null && b.condition === null) return 1;
return 0;
});
}
function buildStepEdges(
sourceId: string,
step: WorkFlowStep,
nameToId: Map<string, string>,
): { elseEdges: AnyWorkEdge[]; ifEdges: AnyWorkEdge[] } {
const hasMultiple = step.transitions.length > 1;
const sorted = sortTransitions(step);
const elseEdges: AnyWorkEdge[] = [];
const ifEdges: AnyWorkEdge[] = [];
for (let i = 0; i < sorted.length; i++) {
const t = sorted[i];
const targetId = nameToId.get(t.target);
if (!targetId) continue;
const edgeId = `e-${sourceId}-${targetId}-${i}`;
if (hasMultiple || t.condition !== null) {
const edge: ConditionalEdge = {
id: edgeId,
source: sourceId,
target: targetId,
sourceHandle: "output",
targetHandle: "input",
type: "conditional",
data: { condition: t.condition ?? "" },
animated: true,
};
if (hasMultiple && i === 0) elseEdges.push(edge);
else ifEdges.push(edge);
} else {
elseEdges.push({
id: edgeId,
source: sourceId,
target: targetId,
sourceHandle: "output",
targetHandle: "input",
animated: true,
});
}
}
return { elseEdges, ifEdges };
}
function pushStepEdges(
edges: AnyWorkEdge[],
elseEdges: AnyWorkEdge[],
ifEdges: AnyWorkEdge[],
idToOrder: Map<string, number>,
): void {
for (const e of elseEdges) edges.push({ ...e, sourceHandle: "output" });
if (ifEdges.length > 0) {
const ifHandles = ["output-top", "output-bottom"] as const;
const sorted = [...ifEdges].sort(
(a, b) => (idToOrder.get(b.target) ?? 0) - (idToOrder.get(a.target) ?? 0),
);
for (let i = 0; i < sorted.length; i++) {
edges.push({ ...sorted[i], sourceHandle: ifHandles[i % ifHandles.length] });
}
}
}
function assignTargetHandles(edges: AnyWorkEdge[], idToOrder: Map<string, number>): void {
const incomingByTarget = new Map<string, number[]>();
for (let i = 0; i < edges.length; i++) {
const target = edges[i].target;
if (!incomingByTarget.has(target)) incomingByTarget.set(target, []);
incomingByTarget.get(target)?.push(i);
}
for (const indices of incomingByTarget.values()) {
indices.sort(
(a, b) => (idToOrder.get(edges[a].source) ?? 0) - (idToOrder.get(edges[b].source) ?? 0),
);
assignHandles(indices, edges, IN_HANDLES, "targetHandle");
}
}
export function transIn(steps: WorkFlowStep[]): Result {
const startNode: AnyWorkNode = {
id: "start",
@@ -42,30 +145,12 @@ export function transIn(steps: WorkFlowStep[]): Result {
position: { x: 250, y: 0 },
};
if (steps.length === 0) {
return { nodes: [startNode, endNode], edges: [] };
}
if (steps.length === 0) return { nodes: [startNode, endNode], edges: [] };
const nodes: AnyWorkNode[] = [startNode, endNode];
const edges: AnyWorkEdge[] = [];
const nameToId = new Map<string, string>();
const idToOrder = new Map<string, number>();
nameToId.set("END", "end");
idToOrder.set("start", -1);
idToOrder.set("end", steps.length);
for (let si = 0; si < steps.length; si++) {
const step = steps[si];
const nodeId = `n${uuid()}`;
nameToId.set(step.role.name, nodeId);
idToOrder.set(nodeId, si);
nodes.push({
id: nodeId,
type: "role",
data: { ...step.role },
position: { x: 0, y: 0 },
});
}
const { nameToId, idToOrder } = buildNodeMap(steps, nodes);
const firstStepId = nameToId.get(steps[0].role.name) ?? "";
edges.push({
@@ -79,88 +164,11 @@ export function transIn(steps: WorkFlowStep[]): Result {
for (const step of steps) {
const sourceId = nameToId.get(step.role.name) ?? "";
const _sourceOrder = idToOrder.get(sourceId) ?? 0;
const hasMultipleTransitions = step.transitions.length > 1;
const sorted = hasMultipleTransitions
? [...step.transitions].sort((a, b) => {
if (a.condition === null && b.condition !== null) return -1;
if (a.condition !== null && b.condition === null) return 1;
return 0;
})
: step.transitions;
const elseEdges: AnyWorkEdge[] = [];
const ifEdges: AnyWorkEdge[] = [];
for (let i = 0; i < sorted.length; i++) {
const t = sorted[i];
const targetId = nameToId.get(t.target);
if (!targetId) continue;
const edgeId = `e-${sourceId}-${targetId}-${i}`;
if (hasMultipleTransitions || t.condition !== null) {
const edge: ConditionalEdge = {
id: edgeId,
source: sourceId,
target: targetId,
sourceHandle: "output",
targetHandle: "input",
type: "conditional",
data: { condition: t.condition ?? "" },
animated: true,
};
if (hasMultipleTransitions && i === 0) {
elseEdges.push(edge);
} else {
ifEdges.push(edge);
}
} else {
elseEdges.push({
id: edgeId,
source: sourceId,
target: targetId,
sourceHandle: "output",
targetHandle: "input",
animated: true,
});
}
}
// out: else → output (right); if → sort by target order desc (rightmost first), then top/bottom
for (const e of elseEdges) {
edges.push({ ...e, sourceHandle: "output" });
}
if (ifEdges.length > 0) {
const sortedIf = [...ifEdges].sort((a, b) => {
const oa = idToOrder.get(a.target) ?? 0;
const ob = idToOrder.get(b.target) ?? 0;
return ob - oa;
});
const ifHandles = ["output-top", "output-bottom"] as const;
for (let i = 0; i < sortedIf.length; i++) {
edges.push({ ...sortedIf[i], sourceHandle: ifHandles[i % ifHandles.length] });
}
}
const { elseEdges, ifEdges } = buildStepEdges(sourceId, step, nameToId);
pushStepEdges(edges, elseEdges, ifEdges, idToOrder);
}
// in: group by target, sort by source order asc (leftmost first), assign input > input-top > input-bottom
const incomingByTarget = new Map<string, number[]>();
for (let i = 0; i < edges.length; i++) {
const target = edges[i].target;
if (!incomingByTarget.has(target)) incomingByTarget.set(target, []);
incomingByTarget.get(target)?.push(i);
}
for (const indices of incomingByTarget.values()) {
indices.sort((a, b) => {
const oa = idToOrder.get(edges[a].source) ?? 0;
const ob = idToOrder.get(edges[b].source) ?? 0;
return oa - ob;
});
assignHandles(indices, edges, IN_HANDLES, "targetHandle");
}
assignTargetHandles(edges, idToOrder);
return { nodes, edges };
}
@@ -91,6 +91,36 @@ function validateEndNode(
}
}
function hasEmptyConditionOnIfEdge(conditionalEdges: AnyWorkEdge[]): boolean {
return conditionalEdges.slice(1).some((edge) => {
const cond = (edge as ConditionalEdge).data?.condition?.trim();
return !cond;
});
}
function validateRoleNodeEdges(
node: AnyWorkNode,
outEdges: AnyWorkEdge[],
inEdges: AnyWorkEdge[],
errors: ValidationError[],
): void {
if (inEdges.length === 0) {
errors.push({ nodeId: node.id, message: "角色节点缺少输入连接" });
}
if (outEdges.length === 0) {
errors.push({ nodeId: node.id, message: "角色节点缺少输出连接" });
return;
}
if (outEdges.length <= 1) return;
const conditionalEdges = outEdges.filter((e) => e.type === "conditional");
if (conditionalEdges.length !== outEdges.length) {
errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带条件" });
} else if (hasEmptyConditionOnIfEdge(conditionalEdges)) {
errors.push({ nodeId: node.id, message: "条件边的条件表达式不能为空" });
}
}
function validateRoleNodes(
roleNodes: AnyWorkNode[],
outgoing: Map<string, AnyWorkEdge[]>,
@@ -98,31 +128,7 @@ function validateRoleNodes(
errors: ValidationError[],
): void {
for (const node of roleNodes) {
const inEdges = incoming.get(node.id) ?? [];
const outEdges = outgoing.get(node.id) ?? [];
if (inEdges.length === 0) {
errors.push({ nodeId: node.id, message: "角色节点缺少输入连接" });
}
if (outEdges.length === 0) {
errors.push({ nodeId: node.id, message: "角色节点缺少输出连接" });
}
if (outEdges.length > 1) {
const conditionalEdges = outEdges.filter((e) => e.type === "conditional");
if (conditionalEdges.length !== outEdges.length) {
errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带条件" });
} else {
const ifEdges = conditionalEdges.slice(1);
for (const edge of ifEdges) {
const condEdge = edge as ConditionalEdge;
if (!condEdge.data?.condition?.trim()) {
errors.push({ nodeId: node.id, message: "条件边的条件表达式不能为空" });
break;
}
}
}
}
validateRoleNodeEdges(node, outgoing.get(node.id) ?? [], incoming.get(node.id) ?? [], errors);
}
}
@@ -0,0 +1,14 @@
import path from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/__tests__/**/*.test.ts"],
},
resolve: {
alias: {
"@": path.resolve(import.meta.dirname, "./src"),
},
},
});
+60
View File
@@ -0,0 +1,60 @@
# @uncaged/workflow-moderator
JSONata-based graph evaluator — determines the next role or `$END` with zero LLM cost.
## Overview
The moderator (Layer 1) walks the workflow graph from the current role. For each outgoing transition it evaluates an optional JSONata condition against `ModeratorContext` (start prompt + prior step outputs). The first truthy transition wins; its target role and edge prompt are returned. When no transition matches, the workflow ends (`$END`).
**Dependencies:** `@uncaged/workflow-protocol`, `jsonata`
## Installation
```bash
bun add @uncaged/workflow-moderator
```
## API
### Functions
```typescript
function evaluate(
workflow: WorkflowPayload,
context: ModeratorContext,
): Promise<Result<EvaluateResult, Error>>
```
Returns `{ ok: true, value: { role, prompt } }` where `role` is the next role name or `"$END"`, and `prompt` is the edge instruction for the agent.
### Types
```typescript
type EvaluateResult = {
role: string;
prompt: string;
};
```
The `Result<T, E>` type is local to this package (`{ ok: true; value: T } | { ok: false; error: E }`), not re-exported from `index.ts`.
## Usage
```typescript
import { evaluate } from "@uncaged/workflow-moderator";
import type { ModeratorContext, WorkflowPayload } from "@uncaged/workflow-protocol";
const result = await evaluate(workflow, context);
if (result.ok && result.value.role !== "$END") {
console.log(`Next role: ${result.value.role}, prompt: ${result.value.prompt}`);
}
```
## Internal Structure
```
src/
├── index.ts Public exports
├── evaluate.ts Graph walk + JSONata condition evaluation
└── types.ts EvaluateResult, Result
```
+193
View File
@@ -0,0 +1,193 @@
# @uncaged/workflow-protocol
Shared TypeScript types and JSON Schema constants for the workflow engine.
## Overview
This is the contract layer (Layer 0). It defines `WorkflowPayload`, thread node payloads, moderator context, CLI output shapes, and configuration types used across every other package. It has no runtime logic beyond exporting schema objects from `@uncaged/json-cas`.
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`
## Installation
```bash
bun add @uncaged/workflow-protocol
```
## API
All exports come from `src/index.ts`.
### JSON Schema constants
```typescript
START_NODE_SCHEMA: JSONSchema
STEP_NODE_SCHEMA: JSONSchema
WORKFLOW_SCHEMA: JSONSchema
```
### Core identifiers
```typescript
type CasRef = string // XXH64 hash, 13-char Crockford Base32
type ThreadId = string // ULID, 26-char Crockford Base32
type WorkflowName = string
type RoleName = string
```
### Workflow definition
```typescript
type RoleDefinition = {
description: string;
goal: string;
capabilities: string[];
procedure: string;
output: string;
frontmatter: CasRef;
};
type Transition = {
role: string;
condition: string | null;
prompt: string;
};
type ConditionDefinition = {
description: string;
expression: string;
};
type WorkflowPayload = {
name: string;
description: string;
roles: Record<string, RoleDefinition>;
conditions: Record<string, ConditionDefinition>;
graph: Record<string, Transition[]>;
};
```
### Thread nodes
```typescript
type StepRecord = {
role: string;
output: CasRef;
detail: CasRef;
agent: string;
edgePrompt: string;
};
type StartNodePayload = {
workflow: CasRef;
prompt: string;
};
type StepNodePayload = StepRecord & {
start: CasRef;
prev: CasRef | null;
};
```
### Moderator context
```typescript
type StepContext = Omit<StepRecord, "output"> & { output: unknown; content: string | null };
type ModeratorContext = {
start: StartNodePayload;
steps: StepContext[];
};
```
### Configuration
```typescript
type ProviderAlias = string;
type ModelAlias = string;
type AgentAlias = string;
type ProviderConfig = { baseUrl: string; apiKeyEnv: string };
type ModelConfig = {
provider: ProviderAlias;
name: string;
};
type AgentConfig = {
command: string;
args: string[];
};
type WorkflowConfig = {
providers: Record<ProviderAlias, ProviderConfig>;
models: Record<ModelAlias, ModelConfig>;
agents: Record<AgentAlias, AgentConfig>;
defaultAgent: AgentAlias;
agentOverrides: Record<WorkflowName, Record<RoleName, AgentAlias>> | null;
defaultModel: ModelAlias;
modelOverrides: Record<Scenario, ModelAlias> | null;
};
```
### CLI output types
```typescript
type StartOutput = { workflow: CasRef; thread: ThreadId };
type StepOutput = {
workflow: CasRef;
thread: ThreadId;
head: CasRef;
done: boolean;
};
type StepEntry = {
hash: CasRef;
role: string;
output: unknown;
detail: CasRef;
agent: string;
timestamp: number;
};
type StartEntry = {
hash: CasRef;
workflow: CasRef;
prompt: string;
timestamp: number;
};
type ThreadStepsOutput = {
thread: ThreadId;
workflow: CasRef;
steps: [StartEntry, ...StepEntry[]];
};
type ThreadForkOutput = {
thread: ThreadId;
forkedFrom: { step: CasRef };
};
type ThreadListItem = {
thread: ThreadId;
workflow: CasRef;
head: CasRef;
};
type ThreadsIndex = Record<ThreadId, CasRef>;
type Scenario = string;
```
## Internal Structure
```
src/
├── index.ts Public re-exports
├── types.ts All type definitions
└── schemas.ts START_NODE_SCHEMA, STEP_NODE_SCHEMA, WORKFLOW_SCHEMA
```
## Configuration
This package defines `WorkflowConfig` types only. Runtime config loading lives in `@uncaged/workflow-agent-kit` (`loadWorkflowConfig`).
+2
View File
@@ -15,6 +15,8 @@ export type {
ProviderConfig,
RoleDefinition,
RoleName,
RunningThreadItem,
RunningThreadsOutput,
Scenario,
StartEntry,
StartNodePayload,
+15
View File
@@ -63,6 +63,7 @@ export type StepNodePayload = StepRecord & {
/** JSONata 上下文中的 step — output 被展开 */
export type StepContext = Omit<StepRecord, "output"> & {
output: unknown;
content: string | null;
};
export type ModeratorContext = {
@@ -84,6 +85,7 @@ export type StepOutput = {
thread: ThreadId;
head: CasRef;
done: boolean;
background: boolean | null;
};
/** uwf thread steps — single step entry */
@@ -126,6 +128,19 @@ export type ThreadListItem = {
head: CasRef;
};
/** uwf thread running — single running thread entry */
export type RunningThreadItem = {
thread: ThreadId;
workflow: CasRef;
pid: number;
startedAt: number;
};
/** uwf thread running output */
export type RunningThreadsOutput = {
threads: RunningThreadItem[];
};
// ── 4.6 配置 ────────────────────────────────────────────────────────
/** Alias types for config references */
@@ -0,0 +1,64 @@
import type { AgentContext } from "@uncaged/workflow-runtime";
/** Max characters of step content to include in the prompt. */
const CONTENT_QUOTA = 16_000;
/** Builds the full agent prompt: system instructions plus summarized thread history. */
export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
const lines: string[] = [];
lines.push(ctx.currentRole.systemPrompt);
lines.push("");
lines.push("## Task");
lines.push(ctx.start.content);
const { steps } = ctx;
if (steps.length === 0) {
return lines.join("\n");
}
if (steps.length === 1) {
const s = steps[0];
lines.push("");
lines.push(`## Step: ${s.role}`);
lines.push("");
lines.push(`Meta: ${JSON.stringify(s.meta)}`);
appendContent(lines, s.content);
} else {
lines.push("");
lines.push("## Previous Steps");
for (let i = 0; i < steps.length - 1; i++) {
const s = steps[i];
lines.push("");
lines.push(`### Step ${i + 1}: ${s.role}`);
lines.push(`Summary: ${JSON.stringify(s.meta)}`);
}
const last = steps[steps.length - 1];
lines.push("");
lines.push(`## Latest Step: ${last.role}`);
lines.push("");
lines.push(`Meta: ${JSON.stringify(last.meta)}`);
appendContent(lines, last.content);
}
lines.push("");
lines.push("## Tools");
lines.push(
`Use \`uncaged-workflow thread ${ctx.threadId}\` to read full details of any previous step.`,
);
return lines.join("\n");
}
function appendContent(lines: string[], content: string | null | undefined): void {
if (content === null || content === undefined || content.trim() === "") {
return;
}
const truncated =
content.length > CONTENT_QUOTA
? `${content.slice(0, CONTENT_QUOTA)}\n... (truncated)`
: content;
lines.push("");
lines.push("<output>");
lines.push(truncated);
lines.push("</output>");
}
+130 -16
View File
@@ -1,32 +1,146 @@
# @uncaged/workflow-util
Shared utilities: encoding, IDs, logging, storage paths, and ref-field normalization.
Shared utilities: encoding, IDs, logging, frontmatter parsing, storage paths, and CLI reference generation.
## What This Package Does
## Overview
It provides filesystem-safe Base32 and ULID generation, the structured logger used across packages, helpers for the default workflow data directory and global CAS path, and utilities to merge/normalize `refs` on steps. It re-exports `ok`/`err` from protocol for convenience.
Layer 1 shared infrastructure used across CLI, agent-kit, and agent packages. Provides Crockford Base32 encoding, ULID generation, structured logging with fixed 8-char tags, frontmatter markdown parsing/validation, process-level debug logging, and helpers for the default workflow data directory.
## Key Exports
**Dependencies:** none (standalone)
From `src/index.ts`:
## Installation
- **Base32:** `CROCKFORD_BASE32_ALPHABET`, `decodeCrockfordBase32Bits`, `decodeCrockfordToUint64`, `encodeCrockfordBase32Bits`, `encodeUint64AsCrockford`
- **Logger:** `createLogger`
- **Refs:** `mergeRefsWithContentHash`, `normalizeRefsField`
- **Result:** `ok`, `err` (from `@uncaged/workflow-protocol`)
- **Paths:** `getDefaultWorkflowStorageRoot`, `getGlobalCasDir`
- **ULID:** `generateUlid`
- **Types:** `CreateLoggerOptions`, `LogFn`, `LoggerSink`, `Result`
```bash
bun add @uncaged/workflow-util
```
## Dependencies
## API
- **Workspace:** `@uncaged/workflow-protocol``Result` and shared types used by helpers
All exports come from `src/index.ts`.
### Encoding and IDs
```typescript
function encodeUint64AsCrockford(value: bigint): string
function generateUlid(nowMs: number): string
function extractUlidTimestamp(ulid: string): number | null
```
### Logging
```typescript
function createLogger(options?: { sink: { kind: "stderr" } }): LogFn
type LogFn = (tag: string, message: string) => void
// CreateLoggerOptions and LoggerSink are internal types
```
### Process logger
```typescript
function createProcessLogger(options: CreateProcessLoggerOptions): ProcessLogger
type ProcessLogger = {
pid: string;
log: ProcessLogFn;
};
type ProcessLoggerContext = {
thread: string | null;
workflow: string | null;
};
type CreateProcessLoggerOptions = {
storageRoot: string | null;
context: ProcessLoggerContext;
};
type ProcessLogFn = (
tag: string,
msg: string,
context: Record<string, string> | null,
) => void;
```
### Frontmatter markdown
```typescript
function parseFrontmatterMarkdown(raw: string): ParsedFrontmatterMarkdown
function validateFrontmatter(
parsed: ParsedFrontmatterMarkdown,
schema: Record<string, unknown>,
): FrontmatterValidationError[]
type ParsedFrontmatterMarkdown = {
frontmatter: Record<string, unknown>;
body: string;
};
type AgentFrontmatter = { /* standard agent frontmatter fields */ };
type FrontmatterScope = string;
type FrontmatterStatus = string;
type FrontmatterValidationError = { path: string; message: string };
```
### Result helpers
```typescript
function ok<T>(value: T): Result<T, never>
function err<E>(error: E): Result<never, E>
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }
```
### Storage paths
```typescript
function getDefaultWorkflowStorageRoot(): string
function getGlobalCasDir(storageRoot: string | undefined): string
```
### Refs and misc
```typescript
function normalizeRefsField(value: unknown): string[]
function generateCliReference(): string
function env(name: string, fallback: string): string
```
## Usage
```typescript
import { createLogger, getDefaultWorkflowStorageRoot, generateUlid } from "@uncaged/workflow-util";
import {
createLogger,
generateUlid,
getDefaultWorkflowStorageRoot,
parseFrontmatterMarkdown,
} from "@uncaged/workflow-util";
const log = createLogger();
log("4KNMR2PX", "example");
log("4KNMR2PX", "Loading workflow...");
const root = getDefaultWorkflowStorageRoot();
const threadId = generateUlid(Date.now());
```
## Internal Structure
```
src/
├── index.ts
├── base32.ts Crockford Base32 encode/decode
├── ulid.ts ULID generation
├── logger.ts Structured logger
├── process-logger/ Process-level debug log files
├── frontmatter-markdown/ Parse and validate agent frontmatter
├── refs-field.ts Normalize refs arrays on CAS nodes
├── result.ts ok / err helpers
├── storage-root.ts Default ~/.uncaged/workflow paths
├── env.ts Environment variable helper
├── cli-reference.ts Markdown CLI reference generator
└── types.ts LogFn, Result, logger options
```
## Configuration
`getDefaultWorkflowStorageRoot()` resolves to `~/.uncaged/workflow` unless overridden by environment (see `storage-root.ts`).
@@ -0,0 +1,55 @@
import { describe, expect, it } from "bun:test";
import { extractUlidTimestamp, generateUlid } from "../ulid.js";
describe("extractUlidTimestamp", () => {
it("should extract correct timestamp from ULID", () => {
const knownTimestamp = Date.UTC(2026, 4, 20, 0, 0, 0);
const ulid = generateUlid(knownTimestamp);
const extracted = extractUlidTimestamp(ulid);
expect(extracted).toBe(knownTimestamp);
});
it("should handle epoch timestamp (timestamp 0)", () => {
const ulid = generateUlid(0);
const extracted = extractUlidTimestamp(ulid);
expect(extracted).toBe(0);
});
it("should handle recent timestamps", () => {
const recentTimestamp = Date.now();
const ulid = generateUlid(recentTimestamp);
const extracted = extractUlidTimestamp(ulid);
expect(extracted).toBe(recentTimestamp);
});
it("should handle max 48-bit timestamp", () => {
const maxTimestamp = 2 ** 48 - 1;
const ulid = generateUlid(maxTimestamp);
const extracted = extractUlidTimestamp(ulid);
expect(extracted).toBe(maxTimestamp);
});
it("should return null for invalid ULID length", () => {
expect(extractUlidTimestamp("")).toBe(null);
expect(extractUlidTimestamp("TOOSHORT")).toBe(null);
expect(extractUlidTimestamp("TOOLONGAAAAAAAAAAAAAAAAAA")).toBe(null);
});
it("should return null for invalid Crockford Base32 characters", () => {
expect(extractUlidTimestamp("INVALID!@#$%^&CHARACTERS")).toBe(null);
});
it("should extract timestamps from multiple ULIDs correctly", () => {
const timestamps = [
Date.UTC(2020, 0, 1, 0, 0, 0),
Date.UTC(2023, 5, 15, 12, 30, 45),
Date.UTC(2026, 11, 31, 23, 59, 59),
];
for (const ts of timestamps) {
const ulid = generateUlid(ts);
const extracted = extractUlidTimestamp(ulid);
expect(extracted).toBe(ts);
}
});
});
+19 -13
View File
@@ -15,7 +15,7 @@ uwf setup --provider <name> --base-url <url> \\
## Workflow Commands
\`\`\`
uwf workflow put <file> # register a workflow from YAML file
uwf workflow add <file> # register a workflow from YAML file
uwf workflow show <id> # show workflow by name or CAS hash
uwf workflow list # list all registered workflows
\`\`\`
@@ -24,20 +24,27 @@ uwf workflow list # list all registered workflows
\`\`\`
uwf thread start <workflow> -p <prompt> # create a thread (no execution)
uwf thread step <thread-id> # execute one moderatoragentextract cycle
uwf thread exec <thread-id> # execute one moderatoragentextract cycle
[--agent <cmd>] # override agent command
[-c, --count <number>] # run multiple steps (default: 1)
[--background] # run in background
uwf thread show <thread-id> # show thread head pointer
uwf thread list # list active threads
[--all] # include archived threads
uwf thread kill <thread-id> # terminate and archive a thread
uwf thread steps <thread-id> # list all steps in a thread
uwf thread list # list threads
[--status <status>] # filter: idle, running, or completed
uwf thread read <thread-id> # render thread context as markdown
[--quota <chars>] # max output characters (default 32000)
[--before <step-hash>] # load steps before this hash (exclusive)
[--start] # include start step in output
uwf thread fork <step-hash> # fork a thread from a specific step
uwf thread step-details <step-hash> # dump full detail node of a step as YAML
uwf thread stop <thread-id> # stop background execution (keep thread active)
uwf thread cancel <thread-id> # cancel thread (stop + move to history)
\`\`\`
## Step Commands
\`\`\`
uwf step list <thread-id> # list all steps in a thread
uwf step show <step-hash> # show details of a specific step
uwf step fork <step-hash> # fork a thread from a specific step
\`\`\`
## CAS Commands
@@ -78,10 +85,9 @@ uwf -V, --version # print version
## Key Concepts
- **Workflow**: YAML definition with roles, conditions, and a routing graph; stored as a CAS node identified by its XXH64 hash.
- **Thread**: A single workflow execution (ULID). State is an immutable CAS chain; active threads are indexed in \`threads.yaml\`.
- **Step**: One moderatoragentextract cycle. Run \`uwf thread step\` repeatedly until \`$END\`.
- **CAS**: Content-Addressed Storage all nodes are immutable and identified by hash.
- **Role**: Named actor with goal, capabilities, procedure, output, and frontmatter schema; the moderator routes between roles.
- **Edge Prompt**: Required instruction on each graph edge the moderator's dispatch message to the agent.
- **Thread**: A running instance of a workflow; points to a chain of CAS step nodes.
- **Step**: One moderatoragentextract cycle; stored as a CAS node with output + detail refs.
- **Turn**: Agent-internal interaction (within a single step); stored per-turn in the detail node.
- **CAS**: Content-addressable store; every artifact (workflows, steps, details, turns) is hashed.
`;
}
+1 -1
View File
@@ -24,4 +24,4 @@ export { normalizeRefsField } from "./refs-field.js";
export { err, ok } from "./result.js";
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
export type { LogFn, Result } from "./types.js";
export { generateUlid } from "./ulid.js";
export { extractUlidTimestamp, generateUlid } from "./ulid.js";
+17 -1
View File
@@ -1,4 +1,4 @@
import { encodeCrockfordBase32Bits } from "./base32.js";
import { decodeCrockfordBase32Bits, encodeCrockfordBase32Bits } from "./base32.js";
const ULID_TIME_BITS = 48;
const ULID_RANDOM_BITS = 80;
@@ -26,3 +26,19 @@ export function generateUlid(nowMs: number): string {
const payload = (time << BigInt(ULID_RANDOM_BITS)) | rand;
return encodeCrockfordBase32Bits(payload, ULID_TIME_BITS + ULID_RANDOM_BITS);
}
/**
* Extract the timestamp (in milliseconds) from a ULID string.
* Returns null if the ULID is invalid.
*/
export function extractUlidTimestamp(ulid: string): number | null {
if (ulid.length !== 26) {
return null;
}
const timestampPart = ulid.slice(0, 10);
const decoded = decodeCrockfordBase32Bits(timestampPart, ULID_TIME_BITS);
if (!decoded.ok) {
return null;
}
return Number(decoded.value);
}
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# batch-solve.sh — solve multiple Gitea issues via solve-issue workflow
#
# Usage:
# ./scripts/batch-solve.sh [--agent CMD] [--repo OWNER/REPO] [--count N] ISSUE_NUM...
#
# Examples:
# ./scripts/batch-solve.sh 448 449
# ./scripts/batch-solve.sh --agent "bun run $(pwd)/packages/workflow-agent-claude-code/src/cli.ts" 448 449
# ./scripts/batch-solve.sh --repo uncaged/workflow --count 15 448 449
set -euo pipefail
AGENT=""
REPO="uncaged/workflow"
COUNT=10
ISSUES=()
while [[ $# -gt 0 ]]; do
case "$1" in
--agent) AGENT="$2"; shift 2 ;;
--repo) REPO="$2"; shift 2 ;;
--count) COUNT="$2"; shift 2 ;;
*) ISSUES+=("$1"); shift ;;
esac
done
if [[ ${#ISSUES[@]} -eq 0 ]]; then
echo "Usage: $0 [--agent CMD] [--repo OWNER/REPO] [--count N] ISSUE_NUM..." >&2
exit 1
fi
AGENT_FLAG=""
if [[ -n "$AGENT" ]]; then
AGENT_FLAG="--agent $AGENT"
fi
TOTAL=${#ISSUES[@]}
PASSED=0
FAILED=0
RESULTS=()
echo "━━━ Batch solve: ${TOTAL} issues ━━━"
echo ""
for i in "${!ISSUES[@]}"; do
ISSUE="${ISSUES[$i]}"
NUM=$((i + 1))
echo "┌─── [$NUM/$TOTAL] Issue #${ISSUE} ───"
# Read issue title
TITLE=$(tea issues "$ISSUE" -r "$REPO" 2>/dev/null | head -1 | sed 's/^# #[0-9]* //' | sed 's/ (.*//' || echo "unknown")
echo "│ Title: $TITLE"
# Start thread
PROMPT="Fix issue #${ISSUE} in ${REPO}. Read the issue first with 'tea issues ${ISSUE} -r ${REPO}' for full spec."
THREAD_JSON=$(uwf thread start solve-issue -p "$PROMPT" 2>&1)
THREAD_ID=$(echo "$THREAD_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin)['thread'])")
echo "│ Thread: $THREAD_ID"
# Run steps
echo "│ Running (max $COUNT steps)..."
# shellcheck disable=SC2086
if STEP_OUTPUT=$(uwf thread step "$THREAD_ID" $AGENT_FLAG -c "$COUNT" 2>&1); then
# Check if done
LAST_DONE=$(echo "$STEP_OUTPUT" | python3 -c "import json,sys; lines=sys.stdin.read().strip(); data=json.loads(lines); print(data[-1].get('done', False))")
if [[ "$LAST_DONE" == "True" ]]; then
echo "│ ✅ Done!"
PASSED=$((PASSED + 1))
RESULTS+=("✅ #${ISSUE}${TITLE}")
else
echo "│ ⚠️ Ran out of steps (not done)"
FAILED=$((FAILED + 1))
RESULTS+=("⚠️ #${ISSUE}${TITLE} (incomplete)")
fi
else
echo "│ ❌ Failed"
FAILED=$((FAILED + 1))
RESULTS+=("❌ #${ISSUE}${TITLE} (error)")
fi
echo "└───"
echo ""
done
echo "━━━ Results: ${PASSED}/${TOTAL} passed, ${FAILED} failed ━━━"
for R in "${RESULTS[@]}"; do
echo " $R"
done