Compare commits

...

74 Commits

Author SHA1 Message Date
xiaoju 96ab097198 test(#566): add A4 retry loop and C1 integration round-trip tests
CI / test (pull_request) Successful in 1m42s
- A4: verify frontmatter retry loop produces correct AdapterOutput JSON
- C1: full round-trip test with mock agent → CLI JSON parsing → CAS verification

小橘 <xiaoju@shazhou.work>
2026-05-28 00:10:05 +00:00
xiaoju b95bbae5fc feat(agent): change adapter stdout from plain stepHash to JSON with full metadata (#566)
- Add AdapterOutput type (stepHash, detailHash, role, frontmatter, body, startedAtMs, completedAtMs)
- Update FrontmatterFastPathResult to include frontmatter record
- Change createAgent to output JSON line instead of plain hash
- Update spawnAgent in cli-workflow to parse JSON
- Add adapter-stdout tests (A-group) and spawn-agent-json tests (B-group)
2026-05-27 23:55:40 +00:00
xiaoju cfa890f83c Merge PR #565: fix/553-edge-prompt-empty (#553)
CI / test (push) Successful in 1m5s
2026-05-27 22:07:53 +00:00
xiaoju 81c08ac7e2 Merge PR #564: fix/557-step-show-json-escape (#557) 2026-05-27 22:07:53 +00:00
xiaoju fdcfcc7eba Merge PR #563: fix/559-thread-show-status (#559) 2026-05-27 22:07:53 +00:00
xiaoju 4972f99ca0 Merge PR #562: fix/561-thread-start-cwd-option (#561) 2026-05-27 22:07:52 +00:00
xiaoju 48bf701281 fix(moderator): detect empty edge prompt after template rendering (#553)
CI / test (pull_request) Successful in 1m31s
When mustache variables in edge prompts resolve to empty strings (because
upstream output lacks the fields), the engine now returns a Result.error
instead of passing an empty --prompt to the agent.

- evaluate.ts: check rendered prompt is non-empty after mustache.render()
- run.ts: improve parseArgv error message for empty --prompt
- Export parseArgv for testability
- Add 7 tests covering all cases from the spec
2026-05-27 17:17:39 +00:00
xiaoju d8cba5eea0 test(cli): add JSON escaping tests for step show output (#557)
CI / test (pull_request) Successful in 1m38s
Add comprehensive tests verifying that `uwf step show` produces valid
JSON output even when step detail nodes contain control characters
(newlines, tabs, carriage returns, etc.) in tool call args and content
fields.

Tests cover:
- Basic control characters (newlines, tabs, CR+LF)
- Backslashes and quotes
- Unicode control characters (U+0001-U+001F)
- Nested CAS refs with control characters
- Large steps with multiple tool calls
- Empty/null values
- YAML output format (unaffected by escaping)

The tests confirm that JSON.stringify() already handles control
character escaping correctly when serializing JavaScript objects
to JSON. No code changes needed - these tests serve as regression
guards to ensure the behavior remains correct.

Fixes #557

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-27 16:53:07 +00:00
xiaoju d9f7648fdd feat(cli): add status field to thread show output
CI / test (pull_request) Successful in 1m30s
- Add ThreadStatus type to workflow-protocol
- Update StepOutput type to include status field alongside deprecated done/background fields
- Implement status computation in cmdThreadShow (idle/running/completed/cancelled)
- Update cmdThreadStepOnce to include status in return values
- Add comprehensive test suite for thread show status scenarios

Fixes #559

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-27 16:31:08 +00:00
xiaoju a2e9dd9785 feat(cli): add --cwd option to thread start command
CI / test (pull_request) Successful in 1m41s
Exposes the existing cwd parameter from cmdThreadStart to the CLI layer,
allowing users to specify a custom working directory for thread execution.

- Added --cwd <path> option to uwf thread start
- Option defaults to process.cwd() when not provided
- Added comprehensive test suite with 4 test cases
- All 328 tests in cli-workflow package pass

Fixes #561
2026-05-27 16:14:48 +00:00
xiaoju 3b498069b6 Merge PR #560: feat(workflow): add thread/edge location support (#558)
CI / test (push) Successful in 2m16s
2026-05-27 15:54:31 +00:00
xiaoju 984d93a6f5 feat(workflow): add thread/edge location support (#558)
CI / test (pull_request) Successful in 3m43s
Implement thread-level and edge-level working directory management:

- Thread-level cwd (required, defaults to process.cwd())
  - Captured at uwf thread start time
  - Stored in StartNodePayload
  - Inherited by all steps unless overridden

- Edge-level location (optional, supports mustache templates)
  - New location: string | null field on Target type
  - Resolved by moderator using previous step's output
  - Example: location: "{{{repoPath}}}"

- Step audit trail
  - Each StepNodePayload records actual cwd where agent executed

Changes:
- workflow-protocol: Add cwd to StartNodePayload & StepRecord, location to Target
- cli-workflow: Thread start captures cwd, moderator resolves location, step execution uses resolved cwd
- workflow-util-agent: Expose cwd in agent context

Tests:
- Protocol type tests (3 scenarios)
- Moderator location resolution tests (5 scenarios)
- Thread-location integration tests (3 scenarios)

All tests pass. Build successful. Backward compatible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-27 15:24:45 +00:00
xiaonuo 2274de29c3 Merge pull request 'fix(cli): mask apiKey in config list (#531)' (#556) from fix/531-config-mask-apikey into main
CI / test (push) Successful in 1m4s
2026-05-27 03:45:51 +00:00
xiaonuo 911cbf2a8a Merge pull request 'feat(cli): add agentOverrides and modelOverrides to config key validation' (#554) from fix/532-config-key-validation into main
CI / test (push) Successful in 1m9s
2026-05-27 03:45:47 +00:00
xiaoju 09a5da2df2 fix(cli): biome format config.test.ts
CI / test (pull_request) Successful in 1m13s
2026-05-27 01:52:44 +00:00
xiaoju e4c228d36e feat(cli): add agentOverrides and modelOverrides to config key validation (#532)
CI / test (pull_request) Successful in 1m1s
- Add agentOverrides (minDepth 3) and modelOverrides (minDepth 2) to VALID_CONFIG_KEYS
- Support per-key minDepth instead of hardcoded 3
- No knownFields for either key (sub-keys are user-defined)
- Add 5 new tests covering valid/invalid paths for both keys

小橘 <xiaoju@shazhou.work>
2026-05-27 01:50:50 +00:00
xiaoju f8de0e913b test(cli): add edge-case tests for maskApiKeys (#531)
- non-provider apiKey fields not masked (scope check)
- empty provider object handled
- null apiKey handled
- grep check for no legacy apiKeyEnv references

小橘 <xiaoju@shazhou.work>
2026-05-27 01:50:36 +00:00
xiaonuo cb97507e9a Merge pull request 'fix(hermes): add engines.bun, document adapter pattern (#551)' (#552) from fix/551-hermes-bin-engines into main
CI / test (push) Successful in 1m9s
CI / test (pull_request) Successful in 1m6s
2026-05-27 01:45:10 +00:00
xiaoju 4b442bb251 fix(hermes): sort imports in test file for biome compliance
CI / test (pull_request) Successful in 1m8s
2026-05-27 01:35:19 +00:00
xiaoju ac53128ff7 fix(hermes): add engines.bun, document adapter pattern (#551)
- Add engines.bun >= 1.0.0 to workflow-agent-hermes package.json
- Update README to explain uwf-hermes is an adapter, not hermes itself
- Update uwf setup --agent help text to mention adapter concept
- Add tests for engines field, shebang, and adapter docs
- Patch uncaged-workflow-cli skill with Agent Adapters section
2026-05-27 01:33:52 +00:00
xiaomo 607366c469 Merge pull request 'feat: add adapter skill + fix commit scope' (#550) from fix/549-commit-scope into main
CI / test (push) Successful in 1m26s
2026-05-26 17:26:47 +00:00
xiaoju 577fb27470 feat: add adapter skill + fix commit scope (#549)
CI / test (pull_request) Successful in 1m30s
- Add 'uwf skill adapter' — guide for building agent adapters.
  Covers: createAgent factory, AgentContext/AgentRunResult types,
  prompt building helpers, session detail storage, registration.
- Fix developer skill: agent-kit → util-agent in commit scope.

Refs #542
Fixes #549
2026-05-26 17:24:48 +00:00
xiaomo 5475dd3f5c Merge pull request 'feat: add developer skill — coding conventions + architecture guide' (#548) from feat/541-skill-developer into main
CI / test (push) Successful in 1m28s
2026-05-26 17:19:16 +00:00
xiaoju 09b7ddf6d0 feat: add developer skill — coding conventions + architecture guide
CI / test (pull_request) Successful in 1m26s
Adds 'uwf skill developer' for contributors to the workflow engine.
Covers: monorepo structure, dependency layers, functional-first conventions,
error handling, logging with tagged logger, development workflow,
testing, publishing, key modules (moderator, extract pipeline, createAgent).

Refs #541
2026-05-26 17:11:07 +00:00
xiaomo c4e94bbe56 Merge pull request 'feat: add author skill — workflow YAML design guide' (#547) from feat/539-skill-author into main
CI / test (push) Successful in 1m11s
2026-05-26 17:04:50 +00:00
xiaoju dbefe793f2 feat: add author skill — workflow YAML design guide
CI / test (pull_request) Successful in 1m4s
Adds 'uwf skill author' for agents/humans designing workflow definitions.
Covers: YAML structure, role definition, frontmatter schema design,
graph routing, edge prompts, self-testing, and common pitfalls.

Refs #539
2026-05-26 17:02:53 +00:00
xiaomo 6483bc4861 Merge pull request 'feat: add user skill — CLI guide with quick start' (#546) from feat/538-skill-user into main
CI / test (push) Successful in 1m40s
2026-05-26 16:27:43 +00:00
xiaoju fecb02b115 feat: add user skill — CLI guide with quick start and typical workflows
CI / test (pull_request) Successful in 1m26s
Adds 'uwf skill user' command for agents/humans using the uwf CLI.
Covers setup, workflow management, thread lifecycle, step operations,
CAS queries, logging, and global options with a Quick Start guide.

Refs #538
2026-05-26 16:24:39 +00:00
xiaomo 87938c1886 Merge pull request 'feat: add actor skill — frontmatter protocol + CAS reference' (#545) from feat/540-skill-actor into main
CI / test (push) Failing after 23s
2026-05-26 15:44:31 +00:00
xiaoju 95a130136b feat: add actor skill — frontmatter protocol + CAS reference
CI / test (pull_request) Failing after 8m9s
Adds 'uwf skill actor' command for agents executing workflow roles.
Covers the two things an actor needs to know:
1. Frontmatter output protocol (status field, schema-defined fields)
2. CAS operations (put, get, refs, walk, merkle DAG pattern)

Refs #540
2026-05-26 15:32:03 +00:00
xiaomo aba5642908 Merge pull request 'ci: use test:ci to skip integration tests in CI' (#543) from fix/ci-skip-integration-tests into main
CI / test (push) Successful in 3m32s
2026-05-26 15:26:02 +00:00
xingyue 168e604602 ci: use test:ci to skip integration tests in CI
CI / test (pull_request) Successful in 9m13s
The HermesAcpClient integration tests require a live Hermes agent
process and always timeout (3 × 120s) in CI containers, causing
every CI run to fail for ~6 minutes before reporting failure.

Switch from `bun run test` to `bun run test:ci` which was already
defined in all testable packages — workflow-agent-hermes's test:ci
runs only unit tests (__tests__/*.test.ts), skipping integration/.
2026-05-26 23:08:16 +08:00
xiaoju d50159c5a7 refactor: split e2e-walkthrough into 6 roles with dedicated cleanup
CI / test (push) Failing after 11m29s
- bootstrap: Docker + bun install + bun link + verify
- config-and-registry: config get/set/list + workflow add/show/list
- thread-ops: thread start/list/show/exec
- inspect: step list/show + thread read + CAS get/has/refs/walk
- cancel-and-fork: cancel + fork + logs
- cleanup: docker rm -f (all fail paths route here)

小橘 🍊
2026-05-26 14:47:44 +00:00
xiaoju 9a7ad34e55 chore: move e2e-walkthrough to .workflows/, fix CI, clean .plan/
CI / test (push) Failing after 11m54s
- e2e-walkthrough.yaml: examples/ → .workflows/ (project workflows, not examples)
- .gitea/workflows/ci.yml: bun test → bun run test (avoid legacy-packages)
- .plan/: removed stale test spec from #335

小橘 🍊
2026-05-26 14:37:46 +00:00
xiaoju 4193157124 refactor(hermes): clean up loadHermesSessionFromDb
CI / test (push) Failing after 11m14s
- Remove unnecessary Promise.resolve() wrappers (sync function)
- Use try/finally for db.close() instead of manual close at each exit
- Flatten nested try/catch

Follow-up to #535 review nits.

小橘 🍊
2026-05-26 14:27:31 +00:00
xiaomo 6ff1414cf0 Merge pull request 'fix(hermes): add SQLite fallback for loadHermesSession' (#536) from fix/535-sqlite-fallback into main
CI / test (push) Failing after 9m23s
Merge pull request #536: fix(hermes): add SQLite fallback for loadHermesSession
2026-05-26 14:24:42 +00:00
xiaoju 37f4203b40 fix(hermes): add SQLite fallback for loadHermesSession (#535)
CI / test (pull_request) Failing after 9m52s
When sessions.write_json_snapshots is disabled, Hermes only writes to
state.db (SQLite). loadHermesSession now falls back to reading from
~/.hermes/state.db when the JSON file is missing.

- Add getHermesDbPath() and loadHermesSessionFromDb() functions
- Use bun:sqlite with readonly mode, try-catch for graceful errors
- JSON file still takes priority (fast path)
- Filter messages to user/assistant/tool roles
- Convert unix timestamps to ISO 8601 strings
2026-05-26 14:19:15 +00:00
xiaoju c4ec22bb4f chore: e2e-walkthrough uses bun link for container-internal uwf
CI / test (push) Failing after 8m19s
外层: bun install -g @uncaged/cli-workflow@0.5.0 (+ agents)
内层: bun link 本地 packages,完全隔离

小橘 🍊
2026-05-26 13:14:54 +00:00
xiaoju 427f47d72c fix: release script uses filtered test, publish 0.5.0
CI / test (push) Failing after 10m18s
小橘 🍊
2026-05-26 13:02:45 +00:00
xiaoju 9f25745e1e chore: exit pre mode, clean stale changesets for 0.5.0 release
小橘 🍊
2026-05-26 13:00:49 +00:00
xiaoju 82247c86ce feat: add e2e-walkthrough workflow definition
CI / test (push) Failing after 8m29s
Dogfooding: uwf tests uwf. Replaces the monolithic bash script with a
4-role workflow (bootstrap → setup-and-registry → thread-lifecycle →
cancel-fork-and-logs), each executing inside an isolated Docker container.

小橘 🍊
2026-05-26 12:49:13 +00:00
xiaoju 0ef2d8fec2 feat: add E2E walkthrough script (Docker-based, WIP)
CI / test (push) Failing after 7m41s
Runs full uwf CLI walkthrough inside a Docker container with isolated
storage root. Tests: setup, workflow add/list/show, thread start/exec/
cancel/fork, step list/show, CAS operations, config get/set, logs.

Approach: mount host $HOME into node:22-bookworm container, override
UNCAGED_WORKFLOW_STORAGE_ROOT with tmpdir. No mock LLM — real agents.

Known issues documented in header comments (jq quoting, apt-get
startup time, lockfile conflicts).

小橘 🍊(NEKO Team)
2026-05-26 12:40:47 +00:00
xiaoju aa14fd08e0 chore: add dev environment check script
CI / test (push) Failing after 8m26s
scripts/check-dev-env.sh validates all prerequisites:
- Runtime: bun, node, python3
- Tools: hermes, claude-code
- Workflow: repo, build, uwf/agent symlinks, config
- Docker (optional, for E2E tests)

Non-interactive, actionable fix instructions on failure.
Designed for both humans and agents.
2026-05-26 12:25:25 +00:00
xiaonuo e43d4f3bbf Merge pull request 'fix: config validation and agent name normalization (#531, #532, #533)' (#534) from fix/531-532-533 into main
CI / test (push) Failing after 9m10s
2026-05-26 06:09:56 +00:00
xiaoju b0c73b5439 fix(cli): fix config masking, agent normalization, and add key validation
CI / test (pull_request) Failing after 17m6s
This commit addresses three related issues in the CLI config and setup commands:

1. Issue #531: Fix config list apiKey masking
   - maskApiKeys() now checks for 'apiKey' instead of 'apiKeyEnv'
   - Updated tests to use apiKey field throughout

2. Issue #532: Add config set key validation
   - Reject unknown top-level keys with helpful error messages
   - Reject unknown nested fields in providers/models/agents
   - Reject incomplete paths and nested paths on scalar keys
   - Added VALID_CONFIG_KEYS schema and validateConfigKey() function

3. Issue #533: Fix agent name double-prefix in setup
   - mergeConfig() now uses _agentNameFromBinary() to normalize agent names
   - 'uwf-hermes' input now produces 'hermes' key with 'uwf-hermes' command
   - Added tests for prefixed agent names

All tests passing, no regressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-26 05:57:55 +00:00
xiaonuo bbbe4651c2 Merge pull request 'refactor: apiKeyEnv → apiKey, store actual secret in config' (#530) from fix/528-refactor-apikey into main
CI / test (push) Failing after 35s
2026-05-26 05:37:51 +00:00
xiaonuo 7dfe0eb6a9 Merge pull request 'feat(cli): add uwf config get/set/list subcommand' (#527) from fix/526-config-subcommand into main
CI / test (push) Has been cancelled
2026-05-26 05:37:32 +00:00
xiaoju 47a4268b9b docs: update all documentation to reflect apiKey refactoring (#528)
CI / test (pull_request) Failing after 33s
Update all documentation files that contained outdated apiKeyEnv
references to use the new apiKey approach.

## Changes

- docs/architecture.md: Update config example to use apiKey field
- docs/wf-stateless-design.md: Update config examples and type
  definitions to use apiKey instead of apiKeyEnv
- docs/builtin-agent-research.md: Update ProviderConfig type
  definition and code examples

All documentation now consistent with the code implementation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-26 05:34:49 +00:00
xiaoju 0c90b88e08 refactor(protocol,cli,agent): replace apiKeyEnv with apiKey (#528)
CI / test (pull_request) Failing after 7m20s
Breaking change: Store API keys directly in config.yaml instead of
environment variable names.

## Changes

### @uncaged/workflow-protocol
- Change ProviderConfig.apiKeyEnv: string → apiKey: string
- Update README to reflect new type

### @uncaged/workflow-util-agent
- extract.ts: Remove dotenv loading, use providerEntry.apiKey directly
- storage.ts: Update normalizeProviders to validate apiKey field
- Update error messages to reference apiKey instead of apiKeyEnv

### @uncaged/cli-workflow
- setup.ts: Write actual API key to config.yaml, not .env
- Remove apiKeyEnvName(), loadEnvFile(), saveEnvFile() functions
- Remove getEnvPath() function
- Update cmdSetup to not return envPath in result
- Update README to reflect config.yaml stores API keys
- Fix setup-validate.test.ts to not expect envPath in result

## Verification
-  bun run build passes
-  All tests pass (260/260 in cli-workflow, 55/55 in util-agent)
-  bun run check passes (only pre-existing warnings)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-26 05:23:33 +00:00
xiaoju 5583a9da00 chore: retrigger CI
CI / test (pull_request) Failing after 1m36s
2026-05-26 05:21:11 +00:00
xiaoju 4a0cb7c615 ci: replace lint+typecheck with unified check step
CI / test (pull_request) Failing after 9m1s
Fixes CI failure — 'lint' script didn't exist in package.json.
bun run check already covers tsc + biome + log-tag lint.
2026-05-26 05:04:47 +00:00
xiaoju fa97a7c92a feat(cli): add uwf config get/set/list subcommand
CI / test (pull_request) Failing after 23m14s
Add configuration management commands to uwf CLI:
- uwf config list: display all config values (masks API keys)
- uwf config get <key>: retrieve specific value using dot notation
- uwf config set <key> <value>: update config value with auto-creation

Implementation:
- New file packages/cli-workflow/src/commands/config.ts with helper functions
- Comprehensive test coverage (32 tests) in config.test.ts
- Supports nested path navigation via dot notation
- Auto-creates intermediate objects when setting new paths
- Masks apiKeyEnv values in list output for security

Resolves #526

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-25 16:21:51 +00:00
xiaomo 0097633a3b Merge pull request 'fix: cancelled threads show distinct "cancelled" status' (#525) from fix/522-cancelled-thread-status into main
CI / test (push) Failing after 5m57s
2026-05-25 15:51:29 +00:00
xiaomo 04591296b2 Merge pull request 'fix: bin entry point to dist/cli.js for node compatibility' (#524) from fix/523-bin-entry-point into main
CI / test (push) Has been cancelled
2026-05-25 15:51:18 +00:00
xiaoju 96039dbbbf fix: cancelled threads show distinct status instead of completed
CI / test (pull_request) Failing after 34s
Fixes #522
2026-05-25 15:39:59 +00:00
xiaoju 5230462b8d fix: bin entry point to dist/cli.js for node compatibility
CI / test (pull_request) Failing after 9m12s
Fixes #523
2026-05-25 15:35:55 +00:00
xiaomo 4a39d3fdef Merge pull request 'feat(skill): expand uwf skill with architecture, yaml, moderator, list subcommands' (#521) from fix/517-expand-skill into main
CI / test (push) Failing after 22m38s
2026-05-25 15:00:34 +00:00
xingyue 4de13cea44 fix: correct skill references and remove hardcoded test path
CI / test (pull_request) Failing after 23m48s
- moderator-reference: use nested map graph format matching evaluate.ts
- yaml-reference: use goal/procedure/output/capabilities/frontmatter fields
  matching actual WorkflowPayload, not fabricated system/outputSchema
- skill.test.ts: replace hardcoded absolute path with __dirname-relative
- skill.test.ts: assert 'frontmatter' instead of 'outputSchema'
2026-05-25 22:59:38 +08:00
xingyue d9d542c570 fix: correct biome suppressions and formatting for #517
CI / test (pull_request) Failing after 9m9s
2026-05-25 22:47:00 +08:00
xingyue cf6115517c fix: auto-fix biome lint violations in skill.test.ts 2026-05-25 22:44:32 +08:00
xingyue 108f134020 feat(skill): add architecture, yaml, moderator, list subcommands (#517) 2026-05-25 22:42:05 +08:00
xiaomo 8123399189 Merge pull request 'fix(uwf-hermes): read turn data from session file instead of ACP stream' (#520) from fix/519-read-session-file into main
CI / test (push) Failing after 17m33s
2026-05-25 14:24:41 +00:00
xingyue 6324122168 fix(uwf-hermes): read turn data from Hermes session file instead of ACP stream
CI / test (pull_request) Failing after 12m19s
Closes #519

The ACP protocol's tool_call updates only carry a display title (not a
structured tool name) and omit rawInput for polished tools, making the
reconstructed messages unusable for step read/show.

Changes:
- hermes.ts: storePromptResult reads ~/.hermes/sessions/session_{id}.json
  via loadHermesSession() instead of using ACP-reconstructed messages
- acp-client.ts: strip message/tool-call collection logic, keep only
  text chunk accumulation for final response extraction
- step.ts: TurnData gains role + toolCalls fields; formatTurnBody
  renders them in step read markdown output
- README: document sessions.write_json_snapshots requirement
2026-05-25 22:21:03 +08:00
xiaoju 25b411f22e Merge pull request 'fix(validate): support enum-based multi-exit frontmatter schemas' (#518) from fix/enum-multi-exit-validation into main
CI / test (push) Failing after 15m56s
2026-05-25 13:23:10 +00:00
xiaoju 54dc8fcb39 fix(validate): support enum-based multi-exit and upgrade json-cas to 0.5.3
CI / test (pull_request) Failing after 5m55s
Two fixes for 'uwf thread start solve-issue' failures:

1. json-cas 0.5.2 (npm) was missing oneOf in ALLOWED_SCHEMA_KEYS.
   Published json-cas 0.5.3 with the fix, bumped all packages to ^0.5.3.

2. Semantic validator only recognized oneOf-based multi-exit schemas.
   Roles using $status with enum (e.g. enum: [approved, rejected]) were
   incorrectly treated as single-exit. Added isEnumMultiExit() support.

Changes:
- validate-semantic.ts: isEnumMultiExit(), getEnumStatuses(), checkSingleExitMustache()
- All package.json: @uncaged/json-cas ^0.5.2 → ^0.5.3
- validate-semantic.test.ts: 5 new enum multi-exit tests (Suite 3b)
- solve-issue-tea-worktree.test.ts: updated for current workflow structure

小橘 🍊
2026-05-25 13:13:51 +00:00
xiaoju a40e1bb847 fix(cli): remove Chinese text from uwf --help description
CI / test (pull_request) Failing after 33s
Remove the annotation line entirely — the layer names are self-explanatory.

小橘 🍊
2026-05-25 12:42:10 +00:00
xiaomo 2c8bcf7996 Merge pull request 'feat(setup): auto-discover and configure agents during uwf setup' (#515) from feat/424-setup-agent-discovery into main
CI / test (push) Failing after 1m34s
2026-05-25 12:39:34 +00:00
xiaoju af2a25bf87 feat(setup): auto-discover and configure agents during uwf setup
CI / test (pull_request) Failing after 14m13s
- Add agent discovery step to cmdSetupInteractive flow
- _promptAgentSelection: discover uwf-* binaries, auto-select if only one,
  prompt user to choose if multiple, show install hints if none found
- mergeConfig: always write selected agent entry, update defaultAgent
- Known agent labels for hermes, claude-code, cursor, builtin
- 10 new tests for _agentNameFromBinary, _printAgentMenu, cmdSetup agent config

Fixes #424
2026-05-25 12:38:40 +00:00
xiaomo 0abc8bcb3e Merge pull request 'fix(test): correct import path in resume-e2e integration test' (#514) from fix/hermes-integration-test-import into main
CI / test (push) Failing after 1m44s
2026-05-25 12:31:27 +00:00
xiaoju 524e00a0a6 fix(test): correct import path in resume-e2e integration test
CI / check (pull_request) Failing after 3m7s
The test file moved to __tests__/integration/ but the import path
was not updated from ../src/ to ../../src/.

小橘 🍊
2026-05-25 12:29:18 +00:00
xingyue eba3c70e76 ci: add gitea actions workflow
CI / test (push) Failing after 6m22s
2026-05-25 19:43:57 +08:00
xiaoju e2d60fa72e fix(test): use valid JSON Schema in workflow-resolution test fixture
CI / check (push) Failing after 32s
The test used a fake CasRef string as frontmatter, which fails
putSchema validation when loading from YAML files. Replace with
a proper JSON Schema object.

Fixes pre-existing failures in workflow-resolution, cas-exit-code,
and thread-step-count tests.
2026-05-25 11:29:05 +00:00
xiaoju dfae96ad45 style: fix biome import ordering after package rename
CI / check (push) Has been cancelled
2026-05-25 11:26:01 +00:00
xiaomo 2f4473f22c Merge pull request 'refactor: rename workflow-agent-kit → workflow-util-agent, merge moderator' (#513) from refactor/512-rename-packages into main
CI / check (push) Has been cancelled
2026-05-25 11:20:39 +00:00
98 changed files with 6228 additions and 681 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@uncaged/workflow-util": patch
---
Replace optionalEnv/requireEnv with unified env(name, fallback) API
-5
View File
@@ -1,5 +0,0 @@
---
"@uncaged/workflow-protocol": patch
---
fix: correct internal dependency versions for prerelease
-5
View File
@@ -1,5 +0,0 @@
---
"@uncaged/workflow-util-agent": patch
---
fix: include create-agent-adapter.ts in published src
-5
View File
@@ -1,5 +0,0 @@
---
"@uncaged/workflow-protocol": patch
---
fix: use npm publish with pinned deps instead of bun publish (workspace:^ resolution bug)
+1 -1
View File
@@ -1,5 +1,5 @@
{
"mode": "pre",
"mode": "exit",
"tag": "alpha",
"initialVersions": {
"@uncaged/cli-workflow": "0.4.5",
-5
View File
@@ -1,5 +0,0 @@
---
"@uncaged/workflow-protocol": minor
---
feat: AgentFn<Opt> type boundary and createAgentAdapter bridging function (RFC #252)
+25
View File
@@ -0,0 +1,25 @@
name: CI
on:
push:
branches: ['*']
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install
- name: Check
run: bun run check
- name: Test
run: bun run test:ci
+2 -1
View File
@@ -12,4 +12,5 @@ packages/workflow-template-develop/develop.esm.js
.DS_Store
*.py
.claude
tmp
tmp.worktrees/
.worktrees/
-83
View File
@@ -1,83 +0,0 @@
# Test Spec: uwf setup model connectivity validation (#335)
## Context
File: `packages/cli-workflow/src/commands/setup.ts`
Test file: `packages/cli-workflow/src/__tests__/setup-validate.test.ts`
After `cmdSetup` writes config, it should send a test chat completion request to verify the configured model is reachable. If validation fails, warn the user (don't abort — config is already saved).
## Implementation Notes
- Add a `validateModel(baseUrl, apiKey, model)` function that sends a minimal chat completion request (`POST /chat/completions` with `messages: [{role:"user",content:"hi"}]`, `max_tokens: 1`)
- Returns `Result<void, string>` — ok if 2xx response, error with reason string otherwise
- Use `AbortSignal.timeout(15_000)` for the request
- Both `cmdSetup` and `cmdSetupInteractive` should call it after saving config
- `cmdSetup` returns validation result in its return object: `{ ...existing, validation: { ok: true } | { ok: false, error: string } }`
- `cmdSetupInteractive` prints a warning to console if validation fails, success message if it passes
- Use the project logger (`createLogger`) — no raw `console.log` except in interactive CLI output (per CLAUDE.md)
## Test Cases (vitest)
### 1. `validateModel` — success path
- Mock `fetch` to return `{ status: 200, ok: true, json: () => ({}) }`
- Call `validateModel(baseUrl, apiKey, model)`
- Assert returns `{ ok: true, value: undefined }`
- Assert fetch was called with correct URL (`${baseUrl}/chat/completions`), correct headers (`Authorization: Bearer ${apiKey}`), correct body (model, messages, max_tokens: 1)
### 2. `validateModel` — HTTP error (401 unauthorized)
- Mock `fetch` to return `{ status: 401, ok: false, statusText: "Unauthorized" }`
- Call `validateModel(baseUrl, apiKey, model)`
- Assert returns `{ ok: false, error: <string containing "401"> }`
### 3. `validateModel` — HTTP error (404 model not found)
- Mock `fetch` to return `{ status: 404, ok: false, statusText: "Not Found" }`
- Assert returns `{ ok: false, error: <string containing "404"> }`
### 4. `validateModel` — network timeout
- Mock `fetch` to throw `DOMException` with name `AbortError`
- Assert returns `{ ok: false, error: <string containing "timeout" or "unreachable"> }`
### 5. `validateModel` — network error (DNS failure, connection refused)
- Mock `fetch` to throw `TypeError("fetch failed")`
- Assert returns `{ ok: false, error: <string mentioning connectivity> }`
### 6. `cmdSetup` — includes validation result on success
- Mock global `fetch` for `/chat/completions` to succeed
- Call `cmdSetup({ provider, baseUrl, apiKey, model, storageRoot })`
- Assert returned object has `validation: { ok: true, value: undefined }`
- Assert config files are still written (existing behavior preserved)
### 7. `cmdSetup` — includes validation result on failure (config still saved)
- Mock global `fetch` for `/chat/completions` to return 401
- Call `cmdSetup({ ... })`
- Assert returned object has `validation: { ok: false, error: ... }`
- Assert `config.yaml` and `.env` are still written (validation failure doesn't prevent saving)
### 8. `cmdSetupInteractive` — prints success message on validation pass
- Mock `fetch` for both `/models` and `/chat/completions` to succeed
- Mock stdin to provide valid selections
- Capture console output
- Assert output contains a success message like "Model verified" or "✓"
### 9. `cmdSetupInteractive` — prints warning on validation failure
- Mock `fetch`: `/models` succeeds, `/chat/completions` returns 401
- Mock stdin for valid selections
- Capture console output
- Assert output contains a warning about model not being reachable and suggests trying a different model
### 10. `validateModel` — request body correctness
- Mock `fetch` to capture the request body
- Call `validateModel(baseUrl, apiKey, "test-model")`
- Assert body is `{ model: "test-model", messages: [{role: "user", content: "hi"}], max_tokens: 1 }`
## Export Requirements
- `validateModel` must be exported (for direct unit testing)
- Signature: `async function validateModel(baseUrl: string, apiKey: string, model: string): Promise<Result<void, string>>`
- `Result` type: `{ ok: true; value: T } | { ok: false; error: E }` (project convention)
## Files to Create/Modify
- **New**: `packages/cli-workflow/src/__tests__/setup-validate.test.ts` — all test cases above
- **Modify**: `packages/cli-workflow/src/commands/setup.ts` — add `validateModel`, integrate into `cmdSetup` and `cmdSetupInteractive`
+269
View File
@@ -0,0 +1,269 @@
name: "e2e-walkthrough"
description: "End-to-end walkthrough of uwf CLI. Dogfooding: uwf tests uwf. Each role validates a phase of the CLI surface inside an isolated Docker container."
roles:
bootstrap:
description: "Start Docker container with isolated storage, verify uwf is runnable"
goal: "You are an E2E test runner. Set up an isolated Docker environment and verify basic uwf functionality."
capabilities:
- docker
- shell
procedure: |
1. Start a Docker container with isolated storage:
```
docker run -d --name uwf-e2e-$$ \
-v $HOME:$HOME \
-e HOME=$HOME \
-e UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage \
-w ~/repos/workflow \
node:22-bookworm \
sleep infinity
```
2. Inside the container, install bun, install deps, then `bun link` all packages
so that `uwf`, `uwf-hermes`, `uwf-builtin` are on PATH (from source):
```
docker exec uwf-e2e-$$ bash -c '
# Install bun
curl -fsSL https://bun.sh/install | bash
export PATH="$HOME/.bun/bin:$PATH"
# Isolated storage
mkdir -p $UNCAGED_WORKFLOW_STORAGE_ROOT
# Install workspace deps
cd ~/repos/workflow && bun install --frozen-lockfile
# bun link each package that has a bin entry
cd packages/cli-workflow && bun link && cd ../..
cd packages/workflow-agent-hermes && bun link && cd ../..
cd packages/workflow-agent-builtin && bun link && cd ../..
'
```
3. Verify all three commands are available inside the container:
```
docker exec uwf-e2e-$$ bash -c 'export PATH="$HOME/.bun/bin:$PATH" && uwf --version'
docker exec uwf-e2e-$$ bash -c 'export PATH="$HOME/.bun/bin:$PATH" && uwf-hermes --help'
docker exec uwf-e2e-$$ bash -c 'export PATH="$HOME/.bun/bin:$PATH" && uwf-builtin --help'
```
4. Copy host config if it exists:
```
docker exec uwf-e2e-$$ bash -c '
if [ -f $HOME/.uncaged/workflow/config.yaml ]; then
cp $HOME/.uncaged/workflow/config.yaml $UNCAGED_WORKFLOW_STORAGE_ROOT/config.yaml
fi
'
```
Report the container name and confirm uwf + agents are working.
Set containerName to the Docker container name for subsequent roles.
output: "Report uwf version and container readiness. Set $status to pass with containerName, or fail with error."
frontmatter:
oneOf:
- properties:
$status: { const: "pass" }
containerName: { type: string }
required: [$status, containerName]
- properties:
$status: { const: "fail" }
error: { type: string }
required: [$status, error]
config-and-registry:
description: "Validate uwf config commands and workflow registration"
goal: "You are an E2E test runner. Validate uwf config operations and workflow registration inside the Docker container."
capabilities:
- docker
- shell
procedure: |
Use the container from the previous step (containerName is in your prompt).
All commands run via: `docker exec <containerName> bash -c '...'`
All commands use `uwf` (installed via `bun link` inside the container).
Remember to set env vars in each exec:
export PATH="$HOME/.bun/bin:$PATH"
export UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage
Config tests:
1. `uwf config list` — verify it returns valid JSON
2. `uwf config set models.test.name test-model` — set a test key
3. `uwf config get models.test.name` — verify it returns "test-model"
Workflow registration tests:
4. `uwf workflow add ~/repos/workflow/examples/solve-issue.yaml` — register workflow
5. Verify the output contains a hash
6. `uwf workflow list` — verify non-empty array
7. Capture the workflow name from the list
8. `uwf workflow show <name>` — verify it returns roles
Report all test results with pass/fail counts.
output: "Report test results. Set $status to pass (with workflowName and containerName) or fail."
frontmatter:
oneOf:
- properties:
$status: { const: "pass" }
workflowName: { type: string }
containerName: { type: string }
required: [$status, workflowName, containerName]
- properties:
$status: { const: "fail" }
error: { type: string }
containerName: { type: string }
required: [$status, error, containerName]
thread-ops:
description: "Test thread start, list, show, and exec"
goal: "You are an E2E test runner. Validate thread creation and execution inside the Docker container."
capabilities:
- docker
- shell
procedure: |
Use the container (containerName) and workflow (workflowName) from your prompt.
All commands via: `docker exec <containerName> bash -c '...'`
Set env: PATH="$HOME/.bun/bin:$PATH" UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage
1. `uwf thread start <workflowName> -p 'E2E test: what is 2+2?'` — capture thread ID from JSON output
2. `uwf thread list` — verify the thread appears in the list
3. `uwf thread show <threadId>` — verify head pointer exists
4. `uwf thread exec <threadId> --agent uwf-builtin` — execute one step
5. Verify exec returns JSON with a head field
Report results. Pass threadId and containerName forward.
output: "Report test results. Set $status to pass (with threadId, workflowName, containerName) or fail."
frontmatter:
oneOf:
- properties:
$status: { const: "pass" }
threadId: { type: string }
workflowName: { type: string }
containerName: { type: string }
required: [$status, threadId, workflowName, containerName]
- properties:
$status: { const: "fail" }
error: { type: string }
containerName: { type: string }
required: [$status, error, containerName]
inspect:
description: "Test step list/show, thread read, and CAS operations"
goal: "You are an E2E test runner. Validate read and inspect operations inside the Docker container."
capabilities:
- docker
- shell
procedure: |
Use the container (containerName) and threadId from your prompt.
All commands via: `docker exec <containerName> bash -c '...'`
Set env: PATH="$HOME/.bun/bin:$PATH" UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage
Step inspection:
1. `uwf step list <threadId>` — verify steps array has length > 1
2. Capture the last step hash from the output
3. `uwf step show <lastStepHash>` — verify it returns a role field
Thread read:
4. `uwf thread read <threadId>` — verify non-empty output
CAS operations:
5. `uwf cas get <lastStepHash>` — verify returns a type field
6. `uwf cas has <lastStepHash>` — verify exits 0
7. `uwf cas refs <lastStepHash>` — list refs (may be empty)
8. `uwf cas walk <lastStepHash>` — verify returns non-empty array
Report results. Pass threadId, lastStepHash, workflowName, containerName forward.
output: "Report test results. Set $status to pass (with threadId, lastStepHash, workflowName, containerName) or fail."
frontmatter:
oneOf:
- properties:
$status: { const: "pass" }
threadId: { type: string }
lastStepHash: { type: string }
workflowName: { type: string }
containerName: { type: string }
required: [$status, threadId, lastStepHash, workflowName, containerName]
- properties:
$status: { const: "fail" }
error: { type: string }
containerName: { type: string }
required: [$status, error, containerName]
cancel-and-fork:
description: "Test thread cancel, step fork, and log inspection"
goal: "You are an E2E test runner. Validate cancel, fork, and log operations inside the Docker container."
capabilities:
- docker
- shell
procedure: |
Use containerName, threadId, lastStepHash, and workflowName from your prompt.
All commands via: `docker exec <containerName> bash -c '...'`
Set env: PATH="$HOME/.bun/bin:$PATH" UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage
Cancel:
1. Start a second thread: `uwf thread start <workflowName> -p 'E2E cancel test'`
2. Cancel it: `uwf thread cancel <secondThreadId>`
3. Verify it appears in completed list: `uwf thread list --status completed`
Fork:
4. Fork from the first thread's last step: `uwf step fork <lastStepHash>`
5. Verify fork creates a new thread with a different ID
Logs:
6. `uwf log list` — verify output (may be empty)
7. `uwf log show --thread <threadId>` — verify runs without error
Report results with summary.
output: "Report test results with summary. Set $status to pass or fail."
frontmatter:
oneOf:
- properties:
$status: { const: "pass" }
containerName: { type: string }
summary: { type: string }
required: [$status, containerName, summary]
- properties:
$status: { const: "fail" }
error: { type: string }
containerName: { type: string }
required: [$status, error, containerName]
cleanup:
description: "Remove Docker container"
goal: "You are an E2E test runner. Clean up the Docker container used for testing."
capabilities:
- docker
- shell
procedure: |
Remove the Docker container (containerName is in your prompt):
1. `docker rm -f <containerName>`
2. Verify the container is gone: `docker ps -a --filter name=<containerName> --format '{{.Names}}'` should return empty
Report cleanup result.
output: "Report cleanup result. Set $status to pass or fail."
frontmatter:
oneOf:
- properties:
$status: { const: "pass" }
summary: { type: string }
required: [$status, summary]
- properties:
$status: { const: "fail" }
error: { type: string }
required: [$status, error]
graph:
$START:
_: { role: "bootstrap", prompt: "Set up the Docker container and verify uwf is runnable." }
bootstrap:
pass: { role: "config-and-registry", prompt: "Container {{{containerName}}} is ready. Validate config and workflow registration." }
fail: { role: "$END", prompt: "Bootstrap failed: {{{error}}}. No container was created." }
config-and-registry:
pass: { role: "thread-ops", prompt: "Config and registry OK. Workflow '{{{workflowName}}}' registered. Container: {{{containerName}}}. Now test thread operations." }
fail: { role: "cleanup", prompt: "Config/registry failed: {{{error}}}. Clean up container {{{containerName}}}." }
thread-ops:
pass: { role: "inspect", prompt: "Thread ops OK. threadId={{{threadId}}}, workflowName={{{workflowName}}}, containerName={{{containerName}}}. Now test inspect operations." }
fail: { role: "cleanup", prompt: "Thread ops failed: {{{error}}}. Clean up container {{{containerName}}}." }
inspect:
pass: { role: "cancel-and-fork", prompt: "Inspect OK. threadId={{{threadId}}}, lastStepHash={{{lastStepHash}}}, workflowName={{{workflowName}}}, containerName={{{containerName}}}. Now test cancel, fork, and logs." }
fail: { role: "cleanup", prompt: "Inspect failed: {{{error}}}. Clean up container {{{containerName}}}." }
cancel-and-fork:
pass: { role: "cleanup", prompt: "All tests passed! {{{summary}}}. Clean up container {{{containerName}}}." }
fail: { role: "cleanup", prompt: "Cancel/fork failed: {{{error}}}. Clean up container {{{containerName}}}." }
cleanup:
pass: { role: "$END", prompt: "E2E walkthrough complete. {{{summary}}}" }
fail: { role: "$END", prompt: "Cleanup failed: {{{error}}}. Manual cleanup may be needed." }
+1
View File
@@ -4,6 +4,7 @@
"includes": [
"**",
"!**/dist",
"!.worktrees",
"!**/node_modules",
"!**/legacy-packages",
"!scripts",
+1 -1
View File
@@ -391,7 +391,7 @@ Everything else is immutable CAS content.
providers:
openrouter:
baseUrl: "https://openrouter.ai/api/v1"
apiKeyEnv: "OPENROUTER_API_KEY"
apiKey: "sk-..."
models:
sonnet:
+2 -2
View File
@@ -402,7 +402,7 @@ workflow 怎么配置和使用 model?
```136:160:packages/workflow-protocol/src/types.ts
export type ProviderConfig = {
baseUrl: string;
apiKeyEnv: string;
apiKey: string;
};
export type ModelConfig = {
@@ -429,7 +429,7 @@ export type WorkflowConfig = {
export function resolveModel(config: WorkflowConfig, alias: ModelAlias): ResolvedLlmProvider {
const modelEntry = config.models[alias];
const providerEntry = config.providers[modelEntry.provider];
const apiKey = process.env[providerEntry.apiKeyEnv];
const apiKey = providerEntry.apiKey;
return { baseUrl: providerEntry.baseUrl, apiKey, model: modelEntry.name };
}
```
+4 -4
View File
@@ -280,13 +280,13 @@ threads.yaml: { "01J7K9M2XNPQR5VWBCDF8G3H4T": "8FWKR3TN5V1QA" }
providers:
openai:
baseUrl: "https://api.openai.com/v1"
apiKeyEnv: "OPENAI_API_KEY"
apiKey: "sk-..."
anthropic:
baseUrl: "https://api.anthropic.com/v1"
apiKeyEnv: "ANTHROPIC_API_KEY"
apiKey: "sk-ant-..."
openrouter:
baseUrl: "https://openrouter.ai/api/v1"
apiKeyEnv: "OPENROUTER_API_KEY"
apiKey: "sk-or-..."
models:
sonnet:
@@ -465,7 +465,7 @@ type Scenario = string; // e.g. "extract"
type ProviderConfig = {
baseUrl: string;
apiKeyEnv: string; // env var name to read API key from
apiKey: string; // API key stored directly
};
type ModelConfig = {
+175 -69
View File
@@ -1,92 +1,198 @@
name: "solve-issue"
description: "End-to-end issue resolution"
description: "TDD-driven issue resolution for small, focused changes. Loop protection relies on engine maxRounds."
roles:
planner:
description: "Creates implementation plan"
goal: "You are a planning agent. You analyze issues and create implementation plans grounded in the actual codebase."
description: "Analyzes issue and outputs a TDD test spec"
goal: "You are a planning agent. You analyze Gitea issues and produce a TDD test specification that downstream roles will implement and verify."
capabilities:
- issue-analysis
- planning
- file-read
- shell
procedure: |
1. Locate the code repository:
- Check if the current working directory is the repo (look for package.json, .git, etc.)
- If the task mentions a repo URL, clone it first.
- If this is a new project, create the repo and note the path.
2. Explore the codebase — read the relevant source files mentioned in the issue. Understand the current architecture, types, and conventions (check CLAUDE.md, CONTRIBUTING.md, .cursor/rules/).
3. Identify which files need changes and what the changes should be, with specific code references.
4. Output the plan with:
- `repoPath`: absolute path to the repository root
- `plan`: detailed implementation plan with file paths and code references
- `steps`: concrete action items for the developer
output: |
Provide repoPath, plan summary, and steps in the frontmatter.
The plan MUST reference actual file paths and code structures you found by reading the source.
Do NOT guess — if you haven't read a file, read it before referencing it.
On first run (no previous steps):
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
2. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
3. Assess whether the issue has enough information to produce a test spec
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output $status=insufficient_info
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
On subsequent runs (bounced back by tester with fix_spec):
1. Read the tester's output from the previous step to understand what's wrong with the spec
2. Revise the test spec accordingly
After producing the test spec:
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
2. Put the hash in frontmatter.plan (required when $status=ready)
3. Set repoPath to the absolute path of the repository root
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
frontmatter:
type: object
properties:
$status:
enum: ["_"]
repoPath:
type: string
plan:
type: string
required: [$status, repoPath, plan]
oneOf:
- properties:
$status: { const: "ready" }
plan: { type: string }
repoPath: { type: string }
required: [$status, plan, repoPath]
- properties:
$status: { const: "insufficient_info" }
required: [$status]
developer:
description: "Implements code changes"
goal: "You are a developer agent. You implement code changes according to plans."
description: "TDD implementation per test spec"
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
capabilities:
- file-edit
- shell
- testing
- coding
procedure: |
1. Read the planner's output to get the repoPath and implementation plan.
2. cd to the repoPath before making any changes.
3. Create a feature branch from the default branch.
4. Implement the plan — write code, tests, and ensure existing tests pass.
5. Run the project's lint/check command (e.g. `bun run check`, `npm run lint`) and fix ALL errors before proceeding. Build and lint must pass cleanly.
6. Commit your changes with a descriptive message referencing the issue.
output: "List all files changed and provide a summary of the implementation."
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
The repo path and other details are provided in your task prompt.
Before starting any work, set up an isolated worktree:
1. cd into the repo path provided in your task prompt
2. `git fetch origin` to get latest refs
3. First time (no existing branch):
- `git worktree add .worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
- `cd .worktrees/fix/<issue-number>-<short-slug> && bun install`
4. If bounced back from reviewer or tester (branch already exists):
- cd into the existing worktree under `.worktrees/fix/<issue-number>-<short-slug>`
- `git fetch origin && git rebase origin/main`
5. ALL subsequent work must happen inside the worktree directory.
Then implement TDD:
6. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner's output in your task prompt)
7. If bounced back from reviewer or tester: read the previous role's feedback in your task prompt
8. Write tests first based on the spec
9. Implement the code to make tests pass
10. Ensure `bun run build` passes with no errors
11. Run `bun test` to verify all tests pass
If you cannot complete the implementation (e.g. the issue is too complex, blocked by external factors,
or repeated attempts fail), set $status=failed with a reason.
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
frontmatter:
type: object
properties:
$status:
enum: ["_"]
filesChanged:
type: array
items:
type: string
summary:
type: string
required: [$status, filesChanged, summary]
oneOf:
- properties:
$status: { const: "done" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "failed" }
reason: { type: string }
required: [$status, reason]
reviewer:
description: "Reviews code changes"
goal: "You are a code reviewer. You review implementations for correctness and quality."
description: "Code standards compliance check"
goal: "You are a code reviewer. You verify code standards compliance — NOT functionality (that's the tester's job)."
capabilities:
- code-review
- static-analysis
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."
The worktree path is provided in your task prompt. cd into it first.
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
Then perform code review:
Hard checks (must all pass):
3. `bun run build` — no build errors
4. `bunx biome check` — no lint violations
5. TypeScript strict mode — no type errors
Soft checks (review against project conventions if CLAUDE.md / .cursor/rules exist):
- Naming conventions, module boundaries, code style
- No `console.log` in production code
- No dynamic imports in production code
Only review standards compliance. Do NOT test functionality.
If rejecting, you MUST explain the specific reason in your output.
output: "Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments)."
frontmatter:
type: object
properties:
$status:
enum: ["approved", "rejected"]
comments:
type: string
required: [$status, comments]
oneOf:
- properties:
$status: { const: "approved" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "rejected" }
comments: { type: string }
worktree: { type: string }
required: [$status, comments, worktree]
tester:
description: "Functional correctness verification"
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
capabilities:
- testing
procedure: |
The worktree path is provided in your task prompt. cd into it first.
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 planner step in the thread history)
3. Verify each scenario in the spec is covered and passing
4. Determine outcome:
- passed: all scenarios verified, tests pass
- fix_code: tests fail or implementation doesn't match spec → send back to developer
- fix_spec: the spec itself is wrong or incomplete → send back to planner
output: "Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
frontmatter:
oneOf:
- properties:
$status: { const: "passed" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "fix_code" }
report: { type: string }
required: [$status, report]
- properties:
$status: { const: "fix_spec" }
report: { type: string }
required: [$status, report]
committer:
description: "Commits and creates PR"
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
capabilities: []
procedure: |
The worktree path, branch name, and repo info are provided in your task prompt.
cd into the worktree first.
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 --repo <owner/repo> --title "..." --description "..."`
- Extract owner/repo from: `git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/'`
- PR description must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
- On tea failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed
5. After PR creation, clean up the worktree:
- cd to the repo root (parent of .worktrees)
- `git worktree remove <worktree-path>`
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
frontmatter:
oneOf:
- properties:
$status: { const: "committed" }
prUrl: { type: string }
required: [$status, prUrl]
- properties:
$status: { const: "hook_failed" }
error: { type: string }
required: [$status, error]
graph:
$START:
_: { role: "planner", prompt: "Analyze the issue described in the task and produce a detailed implementation plan." }
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
planner:
_: { role: "developer", prompt: "Implement the plan from the planner. Write code, tests, and ensure existing tests pass." }
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." }
developer:
_: { role: "reviewer", prompt: "Review the developer's implementation against the plan for correctness and quality." }
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
reviewer:
approved: { role: "$END", prompt: "The review passed. Complete the workflow." }
rejected: { role: "developer", prompt: "The reviewer rejected your implementation. Read their feedback and fix the issues: {{{comments}}}" }
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in repo {{{worktree}}}." }
approved: { role: "tester", prompt: "Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}." }
tester:
fix_code: { role: "developer", prompt: "Tests found code issues: {{{report}}}. Fix and re-submit." }
fix_spec: { role: "planner", prompt: "Tests found spec issues: {{{report}}}. Revise the test spec." }
passed: { role: "committer", prompt: "All tests passed. Commit and push branch {{{branch}}} from {{{worktree}}}." }
committer:
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow complete." }
+1 -1
View File
@@ -14,7 +14,7 @@
"test:ci": "bun run --filter './packages/*' test:ci",
"changeset": "bunx changeset",
"version": "bunx changeset version",
"release": "bun run build && bun test && node scripts/publish-all.mjs"
"release": "bun run build && bun run test && node scripts/publish-all.mjs"
},
"devDependencies": {
"@agentclientprotocol/sdk": "^0.22.1",
+1 -1
View File
@@ -119,7 +119,7 @@ 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`.
Config: `~/.uncaged/workflow/config.yaml` (includes API keys).
### Skill
+3 -3
View File
@@ -8,11 +8,11 @@
],
"type": "module",
"bin": {
"uwf": "./src/cli.ts"
"uwf": "./dist/cli.js"
},
"dependencies": {
"@uncaged/json-cas": "^0.5.2",
"@uncaged/json-cas-fs": "^0.5.2",
"@uncaged/json-cas": "^0.5.3",
"@uncaged/json-cas-fs": "^0.5.3",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^",
@@ -0,0 +1,174 @@
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { registerUwfSchemas } from "../schemas.js";
import { saveThreadsIndex } from "../store.js";
// ── schemas ──────────────────────────────────────────────────────────────────
const OUTPUT_SCHEMA = {
type: "object" as const,
properties: {
$status: { type: "string" as const, enum: ["done", "failed"] },
result: { type: "string" as const },
},
required: ["$status"],
additionalProperties: false,
};
// ── fixture ──────────────────────────────────────────────────────────────────
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-roundtrip-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
describe("C1: adapter JSON round-trip integration", () => {
test("mock agent outputs JSON, CLI parses it and updates thread head in CAS", async () => {
// 1. Set up CAS store with workflow, start node, and output schema
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
const outputSchemaHash = await putSchema(store, OUTPUT_SCHEMA);
const workflowHash = await store.put(schemas.workflow, {
name: "test-roundtrip",
description: "roundtrip integration test",
roles: {
worker: {
description: "Worker role",
goal: "Do work",
capabilities: [],
procedure: "work",
output: "result",
frontmatter: outputSchemaHash,
},
},
graph: {
$START: { _: { role: "worker", prompt: "Do the work", location: null } },
worker: { done: { role: "$END", prompt: "completed", location: null } },
},
});
const startHash = await store.put(schemas.startNode, {
workflow: workflowHash,
prompt: "Test round-trip task",
});
const threadId = "01ROUNDTRIPTEST0000000000" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: startHash });
// 2. Pre-create CAS nodes that the mock agent would produce
const outputHash = await store.put(outputSchemaHash, {
$status: "done",
result: "test-ok",
});
// Use text schema for detail (simple placeholder)
const detailHash = await store.put(schemas.text, "mock detail");
const startedAtMs = 1716600000000;
const completedAtMs = 1716600001500;
const stepHash = await store.put(schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: detailHash,
agent: "uwf-mock",
edgePrompt: "Do the work",
startedAtMs,
completedAtMs,
cwd: tmpDir,
});
// 3. Create a minimal mock agent shell script that just outputs JSON
// The step node is already in CAS — the agent just needs to print the JSON line
const mockAgentPath = join(tmpDir, "mock-agent.sh");
const adapterJson = JSON.stringify({
stepHash,
detailHash,
role: "worker",
frontmatter: { $status: "done", result: "test-ok" },
body: "",
startedAtMs,
completedAtMs,
});
await writeFile(mockAgentPath, `#!/bin/sh\necho '${adapterJson}'\n`, { mode: 0o755 });
// 4. Write config.yaml
const configPath = join(tmpDir, "config.yaml");
await writeFile(
configPath,
`defaultAgent: uwf-hermes\ndefaultModel: test-model\nagentOverrides: null\nagents: {}\nproviders: {}\nmodels: {}\n`,
);
// 5. Run CLI with agent override pointing to our mock
const cliPath = join(import.meta.dirname, "..", "cli.js");
let stdout: string;
let stderr: string;
let exitCode: number;
try {
stdout = execFileSync(
"bun",
["run", cliPath, "thread", "exec", threadId, "--agent", mockAgentPath],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, WORKFLOW_STORAGE_ROOT: tmpDir },
cwd: tmpDir,
timeout: 30000,
},
);
stderr = "";
exitCode = 0;
} catch (e: unknown) {
const err = e as NodeJS.ErrnoException & {
stdout?: string;
stderr?: string;
status?: number;
};
stdout = err.stdout ?? "";
stderr = err.stderr ?? "";
exitCode = err.status ?? 1;
}
// 6. Verify
if (exitCode !== 0) {
throw new Error(`CLI exited with code ${exitCode}\nstdout: ${stdout}\nstderr: ${stderr}`);
}
// Parse CLI output
const cliOutput = JSON.parse(stdout.trim());
expect(cliOutput).toHaveProperty("thread", threadId);
expect(cliOutput).toHaveProperty("head", stepHash);
expect(cliOutput.head).toMatch(/^[0-9A-HJ-NP-TV-Z]{13}$/);
// Verify the CAS step node exists and has correct metadata
const storeAfter = createFsStore(casDir);
const stepNode = storeAfter.get(cliOutput.head as CasRef);
expect(stepNode).not.toBeNull();
const payload = stepNode!.payload as StepNodePayload;
expect(payload.role).toBe("worker");
expect(payload.agent).toBe("uwf-mock");
expect(payload.startedAtMs).toBe(1716600000000);
expect(payload.completedAtMs).toBe(1716600001500);
expect(payload.output).toBe(outputHash);
expect(payload.detail).toBe(detailHash);
});
});
@@ -0,0 +1,737 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import {
cmdConfigGet,
cmdConfigList,
cmdConfigSet,
getConfigPath,
getNestedValue,
maskApiKeys,
parseDotPath,
setNestedValue,
} from "../commands/config.js";
describe("config command", () => {
// Helper function to create a test config
function createTestConfig(tempDir: string, content: string): string {
const configPath = getConfigPath(tempDir);
writeFileSync(configPath, content, "utf8");
return configPath;
}
// Sample test config
const sampleConfig = `providers:
dashscope:
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
apiKey: sk-test-dashscope-key
openai:
baseUrl: https://api.openai.com/v1
apiKey: sk-test-openai-key
models:
default:
provider: dashscope
name: qwen-max
gpt4:
provider: openai
name: gpt-4
agents:
hermes:
command: uwf-hermes
args:
- --provider
- dashscope
claude-code:
command: claude-code
args:
- --profile
- work
defaultAgent: hermes
defaultModel: default
`;
describe("helper functions", () => {
describe("parseDotPath", () => {
test("splits dot notation correctly", () => {
expect(parseDotPath("a.b.c")).toEqual(["a", "b", "c"]);
expect(parseDotPath("defaultAgent")).toEqual(["defaultAgent"]);
expect(parseDotPath("providers.dashscope.baseUrl")).toEqual([
"providers",
"dashscope",
"baseUrl",
]);
});
});
describe("getNestedValue", () => {
test("traverses nested objects", () => {
const obj = {
a: { b: { c: "value" } },
x: "simple",
};
expect(getNestedValue(obj, ["a", "b", "c"])).toBe("value");
expect(getNestedValue(obj, ["x"])).toBe("simple");
});
test("returns undefined for non-existent paths", () => {
const obj = { a: { b: "value" } };
expect(getNestedValue(obj, ["a", "c"])).toBeUndefined();
expect(getNestedValue(obj, ["x", "y"])).toBeUndefined();
});
});
describe("setNestedValue", () => {
test("creates intermediate objects and sets value", () => {
const obj: Record<string, unknown> = {};
setNestedValue(obj, ["a", "b", "c"], "value");
expect(obj).toEqual({ a: { b: { c: "value" } } });
});
test("preserves existing values", () => {
const obj: Record<string, unknown> = { a: { x: "keep" } };
setNestedValue(obj, ["a", "b"], "new");
expect(obj).toEqual({ a: { x: "keep", b: "new" } });
});
test("overwrites existing value at path", () => {
const obj: Record<string, unknown> = { a: { b: "old" } };
setNestedValue(obj, ["a", "b"], "new");
expect(obj).toEqual({ a: { b: "new" } });
});
});
describe("maskApiKeys", () => {
test("deep clones and masks all apiKey values in providers", () => {
const config = {
providers: {
dashscope: {
baseUrl: "https://example.com",
apiKey: "sk-test-key-12345",
},
openai: {
baseUrl: "https://api.openai.com",
apiKey: "sk-another-secret",
},
},
models: {
default: { provider: "dashscope" },
},
};
const masked = maskApiKeys(config);
expect(masked).toEqual({
providers: {
dashscope: {
baseUrl: "https://example.com",
apiKey: "***MASKED***",
},
openai: {
baseUrl: "https://api.openai.com",
apiKey: "***MASKED***",
},
},
models: {
default: { provider: "dashscope" },
},
});
// Ensure it's a deep clone
expect(masked).not.toBe(config);
});
test("handles config without providers", () => {
const config = { models: { default: { provider: "test" } } };
const masked = maskApiKeys(config);
expect(masked).toEqual(config);
});
test("does not mask non-provider apiKey fields", () => {
const config = {
apiKey: "root-level-key",
providers: {
dashscope: { apiKey: "sk-secret" },
},
models: {
default: { provider: "dashscope" },
},
};
const masked = maskApiKeys(config);
// Root-level apiKey should NOT be masked
expect(masked.apiKey).toBe("root-level-key");
// Provider apiKey SHOULD be masked
const providers = masked.providers as Record<string, Record<string, unknown>>;
expect(providers.dashscope.apiKey).toBe("***MASKED***");
});
test("handles empty provider object", () => {
const config = {
providers: { dashscope: {} },
};
const masked = maskApiKeys(config);
expect(masked).toEqual({ providers: { dashscope: {} } });
});
test("handles provider with null apiKey", () => {
const config = {
providers: {
dashscope: { apiKey: null, baseUrl: "https://example.com" },
},
};
const masked = maskApiKeys(config);
const providers = masked.providers as Record<string, Record<string, unknown>>;
expect(providers.dashscope.apiKey).toBe("***MASKED***");
expect(providers.dashscope.baseUrl).toBe("https://example.com");
});
});
});
describe("cmdConfigList", () => {
test("returns full config when file exists", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const result = await cmdConfigList(tempDir);
expect(result).toBeDefined();
expect(typeof result).toBe("object");
expect(result).toHaveProperty("providers");
expect(result).toHaveProperty("models");
expect(result).toHaveProperty("agents");
expect(result).toHaveProperty("defaultAgent");
expect(result).toHaveProperty("defaultModel");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("masks all apiKey values in providers section", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const result = (await cmdConfigList(tempDir)) as Record<string, unknown>;
const providers = result.providers as Record<string, unknown>;
const dashscope = providers.dashscope as Record<string, unknown>;
const openai = providers.openai as Record<string, unknown>;
expect(dashscope.apiKey).toBe("***MASKED***");
expect(openai.apiKey).toBe("***MASKED***");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("throws error when config file doesn't exist", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
await expect(cmdConfigList(tempDir)).rejects.toThrow();
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("returns empty object when config file is empty", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, "");
const result = await cmdConfigList(tempDir);
expect(result).toEqual({});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("throws error when config file is invalid YAML", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, "invalid: yaml: [broken");
await expect(cmdConfigList(tempDir)).rejects.toThrow();
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
describe("cmdConfigGet", () => {
test("retrieves top-level string value (defaultAgent)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const result = await cmdConfigGet(tempDir, "defaultAgent");
expect(result).toBe("hermes");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("retrieves top-level string value (defaultModel)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const result = await cmdConfigGet(tempDir, "defaultModel");
expect(result).toBe("default");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("retrieves nested object (providers.dashscope)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const result = await cmdConfigGet(tempDir, "providers.dashscope");
expect(result).toEqual({
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: "sk-test-dashscope-key",
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("retrieves deeply nested string (providers.dashscope.baseUrl)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const result = await cmdConfigGet(tempDir, "providers.dashscope.baseUrl");
expect(result).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("retrieves nested string in models (models.default.provider)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const result = await cmdConfigGet(tempDir, "models.default.provider");
expect(result).toBe("dashscope");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("retrieves array value (agents.hermes.args)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const result = await cmdConfigGet(tempDir, "agents.hermes.args");
expect(result).toEqual(["--provider", "dashscope"]);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("throws error when key doesn't exist", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigGet(tempDir, "nonexistent.key")).rejects.toThrow(/Key not found/);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("throws error when config file doesn't exist", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
await expect(cmdConfigGet(tempDir, "defaultAgent")).rejects.toThrow();
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("throws error when accessing property on non-object", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigGet(tempDir, "defaultAgent.foo")).rejects.toThrow();
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
describe("cmdConfigSet", () => {
test("sets top-level string value (defaultAgent)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const result = await cmdConfigSet(tempDir, "defaultAgent", "claude-code");
expect(result).toEqual({ key: "defaultAgent", value: "claude-code" });
// Verify it was written
const updated = await cmdConfigGet(tempDir, "defaultAgent");
expect(updated).toBe("claude-code");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("sets nested string value (providers.dashscope.baseUrl)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const newUrl = "https://new-api.example.com/v1";
const result = await cmdConfigSet(tempDir, "providers.dashscope.baseUrl", newUrl);
expect(result).toEqual({
key: "providers.dashscope.baseUrl",
value: newUrl,
});
// Verify it was written
const updated = await cmdConfigGet(tempDir, "providers.dashscope.baseUrl");
expect(updated).toBe(newUrl);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("creates new nested path (providers.newprovider.baseUrl)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const newUrl = "https://new-provider.com/v1";
const result = await cmdConfigSet(tempDir, "providers.newprovider.baseUrl", newUrl);
expect(result).toEqual({
key: "providers.newprovider.baseUrl",
value: newUrl,
});
// Verify it was created
const updated = await cmdConfigGet(tempDir, "providers.newprovider.baseUrl");
expect(updated).toBe(newUrl);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("sets array value for args key with valid JSON array", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const newArgs = '["--new", "--flags"]';
const result = await cmdConfigSet(tempDir, "agents.hermes.args", newArgs);
expect(result).toEqual({
key: "agents.hermes.args",
value: ["--new", "--flags"],
});
// Verify it was written
const updated = await cmdConfigGet(tempDir, "agents.hermes.args");
expect(updated).toEqual(["--new", "--flags"]);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("preserves existing config values when updating one key", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await cmdConfigSet(tempDir, "defaultAgent", "claude-code");
// Verify other values are preserved
const defaultModel = await cmdConfigGet(tempDir, "defaultModel");
expect(defaultModel).toBe("default");
const dashscopeUrl = await cmdConfigGet(tempDir, "providers.dashscope.baseUrl");
expect(dashscopeUrl).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("creates config file if it doesn't exist", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
const result = await cmdConfigSet(tempDir, "defaultAgent", "hermes");
expect(result).toEqual({ key: "defaultAgent", value: "hermes" });
// Verify file was created
const configPath = getConfigPath(tempDir);
const content = readFileSync(configPath, "utf8");
expect(content).toContain("defaultAgent: hermes");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("throws error when setting property on non-object", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "defaultAgent.foo", "bar")).rejects.toThrow();
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("throws error when array value is invalid JSON for args key", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(
cmdConfigSet(tempDir, "agents.hermes.args", "[invalid json"),
).rejects.toThrow();
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("sets deeply nested model config (models.gpt4.provider)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const result = await cmdConfigSet(tempDir, "models.gpt4.provider", "new-provider");
expect(result).toEqual({
key: "models.gpt4.provider",
value: "new-provider",
});
// Verify it was written
const updated = await cmdConfigGet(tempDir, "models.gpt4.provider");
expect(updated).toBe("new-provider");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("sets agent command (agents.claude-code.command)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
const result = await cmdConfigSet(tempDir, "agents.claude-code.command", "new-command");
expect(result).toEqual({
key: "agents.claude-code.command",
value: "new-command",
});
// Verify it was written
const updated = await cmdConfigGet(tempDir, "agents.claude-code.command");
expect(updated).toBe("new-command");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
describe("cmdConfigSet validation", () => {
test("rejects unknown top-level key", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "unknownKey", "value")).rejects.toThrow(
/Unknown config key.*unknownKey/,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects unknown nested key in providers", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(
cmdConfigSet(tempDir, "providers.myProvider.unknownField", "value"),
).rejects.toThrow(/Unknown field.*unknownField.*providers/);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects unknown nested key in models", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "models.default.invalidField", "value")).rejects.toThrow(
/Unknown field.*invalidField.*models/,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects unknown nested key in agents", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "agents.hermes.badField", "value")).rejects.toThrow(
/Unknown field.*badField.*agents/,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects nested path on scalar key (defaultAgent)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "defaultAgent.foo", "value")).rejects.toThrow(
/defaultAgent.*scalar|Cannot set property/i,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects nested path on scalar key (defaultModel)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "defaultModel.bar", "value")).rejects.toThrow(
/defaultModel.*scalar|Cannot set property/i,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects incomplete nested path (providers without field)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "providers.myProvider", "value")).rejects.toThrow(
/incomplete path|must specify a field/i,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects incomplete nested path (models without field)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "models.myModel", "value")).rejects.toThrow(
/incomplete path|must specify a field/i,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects incomplete nested path (agents without field)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "agents.myAgent", "value")).rejects.toThrow(
/incomplete path|must specify a field/i,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("allows valid nested keys in providers", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await cmdConfigSet(tempDir, "providers.newprovider.baseUrl", "https://example.com");
await cmdConfigSet(tempDir, "providers.newprovider.apiKey", "sk-test");
const baseUrl = await cmdConfigGet(tempDir, "providers.newprovider.baseUrl");
const apiKey = await cmdConfigGet(tempDir, "providers.newprovider.apiKey");
expect(baseUrl).toBe("https://example.com");
expect(apiKey).toBe("sk-test");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("allows valid nested keys in models", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await cmdConfigSet(tempDir, "models.gpt4.provider", "openai");
await cmdConfigSet(tempDir, "models.gpt4.name", "gpt-4o");
const provider = await cmdConfigGet(tempDir, "models.gpt4.provider");
const name = await cmdConfigGet(tempDir, "models.gpt4.name");
expect(provider).toBe("openai");
expect(name).toBe("gpt-4o");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("allows valid nested keys in agents", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await cmdConfigSet(tempDir, "agents.hermes.command", "uwf-hermes");
await cmdConfigSet(tempDir, "agents.hermes.args", '["--flag"]');
const command = await cmdConfigGet(tempDir, "agents.hermes.command");
const args = await cmdConfigGet(tempDir, "agents.hermes.args");
expect(command).toBe("uwf-hermes");
expect(args).toEqual(["--flag"]);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("agentOverrides — accepts valid 3-segment path", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await cmdConfigSet(tempDir, "agentOverrides.solve-issue.planner", "claude-code");
const value = await cmdConfigGet(tempDir, "agentOverrides.solve-issue.planner");
expect(value).toBe("claude-code");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("agentOverrides — rejects incomplete path (2 segments)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "agentOverrides.solve-issue", "hermes")).rejects.toThrow(
/incomplete path|must specify a field/i,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("modelOverrides — accepts valid 2-segment path", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await cmdConfigSet(tempDir, "modelOverrides.extract", "gpt4");
const value = await cmdConfigGet(tempDir, "modelOverrides.extract");
expect(value).toBe("gpt4");
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("modelOverrides — rejects incomplete path (1 segment only)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "modelOverrides", "gpt4")).rejects.toThrow(
/incomplete path|must specify a field/i,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("rejects unknown top-level key (regression)", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "test-config-"));
try {
createTestConfig(tempDir, sampleConfig);
await expect(cmdConfigSet(tempDir, "randomKey", "value")).rejects.toThrow(
/Unknown config key/,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
describe("no legacy apiKeyEnv references", () => {
test("config.ts has no references to apiKeyEnv", () => {
const configSource = readFileSync(join(__dirname, "..", "commands", "config.ts"), "utf8");
expect(configSource).not.toContain("apiKeyEnv");
});
test("config.test.ts has no references to apiKeyEnv (except this test)", () => {
const testSource = readFileSync(__filename, "utf8");
// Remove this test block's own mentions before checking
const withoutThisTest = testSource.replace(
/describe\("no legacy apiKeyEnv references"[\s\S]*$/,
"",
);
expect(withoutThisTest).not.toContain("apiKeyEnv");
});
});
});
@@ -1,21 +1,21 @@
import { describe, expect, test } from "vitest";
import type { Target, WorkflowPayload } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { evaluate } from "../moderator/evaluate.js";
const solveIssueGraph: WorkflowPayload["graph"] = {
$START: {
_: { role: "planner", prompt: "Start planning from the issue in the task." },
_: { role: "planner", prompt: "Start planning from the issue in the task.", location: null },
},
planner: {
_: { role: "developer", prompt: "Implement the plan: {{plan}}" },
_: { role: "developer", prompt: "Implement the plan: {{plan}}", location: null },
},
developer: {
_: { role: "reviewer", prompt: "Review the changes: {{summary}}" },
_: { role: "reviewer", prompt: "Review the changes: {{summary}}", location: null },
},
reviewer: {
approved: { role: "$END", prompt: "Done." },
rejected: { role: "developer", prompt: "Fix: {{comments}}" },
approved: { role: "$END", prompt: "Done.", location: null },
rejected: { role: "developer", prompt: "Fix: {{comments}}", location: null },
},
};
@@ -24,7 +24,11 @@ describe("evaluate", () => {
const result = evaluate(solveIssueGraph, "$START", { $status: "_" });
expect(result).toEqual({
ok: true,
value: { role: "planner", prompt: "Start planning from the issue in the task." },
value: {
role: "planner",
prompt: "Start planning from the issue in the task.",
location: null,
},
});
});
@@ -35,7 +39,7 @@ describe("evaluate", () => {
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: "Fix: missing tests" },
value: { role: "developer", prompt: "Fix: missing tests", location: null },
});
});
@@ -43,7 +47,7 @@ describe("evaluate", () => {
const result = evaluate(solveIssueGraph, "reviewer", { $status: "approved" });
expect(result).toEqual({
ok: true,
value: { role: "$END", prompt: "Done." },
value: { role: "$END", prompt: "Done.", location: null },
});
});
@@ -70,7 +74,11 @@ describe("evaluate", () => {
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" },
value: {
role: "developer",
prompt: "Implement the plan: Add auth middleware",
location: null,
},
});
});
@@ -81,14 +89,14 @@ describe("evaluate", () => {
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: 'Fix: use <T> & "Result<T, E>" types' },
value: { role: "developer", prompt: 'Fix: use <T> & "Result<T, E>" types', location: null },
});
});
test("triple mustache also works for unescaped output", () => {
const graph: Record<string, Record<string, Target>> = {
reviewer: {
_: { role: "developer", prompt: "Fix: {{{comments}}}" },
_: { role: "developer", prompt: "Fix: {{{comments}}}", location: null },
},
};
const result = evaluate(graph, "reviewer", {
@@ -97,7 +105,7 @@ describe("evaluate", () => {
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: "Fix: <script>alert(1)</script>" },
value: { role: "developer", prompt: "Fix: <script>alert(1)</script>", location: null },
});
});
@@ -107,7 +115,11 @@ describe("evaluate", () => {
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" },
value: {
role: "developer",
prompt: "Implement the plan: Add auth middleware",
location: null,
},
});
});
@@ -117,6 +129,7 @@ describe("evaluate", () => {
_: {
role: "developer",
prompt: "Address: {{review.comments}}",
location: null,
},
},
};
@@ -126,7 +139,7 @@ describe("evaluate", () => {
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: "Address: refactor the handler" },
value: { role: "developer", prompt: "Address: refactor the handler", location: null },
});
});
});
@@ -40,6 +40,7 @@ describe("resolveHeadHash", () => {
workflow: workflowHash,
head: headHash,
completedAt: Date.now(),
reason: null,
});
const result = await resolveHeadHash(tmpDir, threadId);
@@ -64,6 +65,7 @@ describe("resolveHeadHash", () => {
workflow: workflowHash,
head: historicalHash,
completedAt: Date.now(),
reason: null,
});
const result = await resolveHeadHash(tmpDir, threadId);
@@ -87,18 +89,21 @@ describe("resolveHeadHash", () => {
workflow: workflowHash,
head: hash1,
completedAt: Date.now() - 2000,
reason: null,
});
await appendThreadHistory(tmpDir, {
thread: threadId2,
workflow: workflowHash,
head: hash2,
completedAt: Date.now() - 1000,
reason: null,
});
await appendThreadHistory(tmpDir, {
thread: threadId3,
workflow: workflowHash,
head: hash3,
completedAt: Date.now(),
reason: null,
});
const result = await resolveHeadHash(tmpDir, threadId2);
@@ -0,0 +1,167 @@
import { readFileSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { parse } from "yaml";
import { _agentNameFromBinary, _printAgentMenu, cmdSetup } from "../commands/setup.js";
// ─── _agentNameFromBinary ────────────────────────────────────────────────────
describe("_agentNameFromBinary", () => {
test("strips uwf- prefix", () => {
expect(_agentNameFromBinary("uwf-hermes")).toBe("hermes");
});
test("strips uwf- prefix for compound names", () => {
expect(_agentNameFromBinary("uwf-claude-code")).toBe("claude-code");
});
test("returns as-is when no uwf- prefix", () => {
expect(_agentNameFromBinary("hermes")).toBe("hermes");
});
test("handles uwf-builtin", () => {
expect(_agentNameFromBinary("uwf-builtin")).toBe("builtin");
});
});
// ─── _printAgentMenu ─────────────────────────────────────────────────────────
describe("_printAgentMenu", () => {
test("prints known agents with labels", () => {
const logs: string[] = [];
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
logs.push(args.join(" "));
});
_printAgentMenu(["uwf-hermes", "uwf-claude-code"]);
expect(logs.some((l) => l.includes("Hermes"))).toBe(true);
expect(logs.some((l) => l.includes("Claude Code"))).toBe(true);
vi.restoreAllMocks();
});
test("prints unknown agents with binary name as label", () => {
const logs: string[] = [];
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
logs.push(args.join(" "));
});
_printAgentMenu(["uwf-custom-agent"]);
expect(logs.some((l) => l.includes("uwf-custom-agent"))).toBe(true);
vi.restoreAllMocks();
});
});
// ─── cmdSetup agent config ───────────────────────────────────────────────────
describe("cmdSetup agent configuration", () => {
let storageRoot: string;
beforeEach(async () => {
storageRoot = await mkdtemp(join(tmpdir(), "uwf-setup-agent-"));
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(storageRoot, { recursive: true, force: true });
});
const baseArgs = () => ({
provider: "testprovider",
baseUrl: "https://api.test.com/v1",
apiKey: "sk-test",
model: "test-model",
storageRoot,
});
test("defaults to hermes agent when no agent specified", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
const result = await cmdSetup(baseArgs());
expect(result.defaultAgent).toBe("hermes");
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
expect(config.agents.hermes).toEqual({ command: "uwf-hermes", args: [] });
expect(config.defaultAgent).toBe("hermes");
});
test("writes specified agent as default", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
const result = await cmdSetup({ ...baseArgs(), agent: "claude-code" });
expect(result.defaultAgent).toBe("claude-code");
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
expect(config.agents["claude-code"]).toEqual({ command: "uwf-claude-code", args: [] });
expect(config.defaultAgent).toBe("claude-code");
});
test("preserves existing agents when adding new one", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
// First setup with hermes
await cmdSetup(baseArgs());
// Second setup with claude-code
await cmdSetup({ ...baseArgs(), agent: "claude-code" });
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
expect(config.agents.hermes).toBeDefined();
expect(config.agents["claude-code"]).toBeDefined();
expect(config.defaultAgent).toBe("claude-code");
});
test("updates defaultAgent on re-run with different agent", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
await cmdSetup(baseArgs());
const config1 = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
expect(config1.defaultAgent).toBe("hermes");
await cmdSetup({ ...baseArgs(), agent: "builtin" });
const config2 = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
expect(config2.defaultAgent).toBe("builtin");
});
test("normalizes agent name with uwf- prefix to bare name", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
const result = await cmdSetup({ ...baseArgs(), agent: "uwf-hermes" });
expect(result.defaultAgent).toBe("hermes");
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
expect(config.agents.hermes).toEqual({ command: "uwf-hermes", args: [] });
expect(config.defaultAgent).toBe("hermes");
// Verify no duplicate uwf- prefix
expect(config.agents["uwf-hermes"]).toBeUndefined();
});
test("normalizes uwf-claude-code to claude-code", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
const result = await cmdSetup({ ...baseArgs(), agent: "uwf-claude-code" });
expect(result.defaultAgent).toBe("claude-code");
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
expect(config.agents["claude-code"]).toEqual({ command: "uwf-claude-code", args: [] });
expect(config.defaultAgent).toBe("claude-code");
// Verify no duplicate uwf- prefix
expect(config.agents["uwf-claude-code"]).toBeUndefined();
});
});
@@ -129,9 +129,8 @@ describe("cmdSetup with validation", () => {
const result = await cmdSetup(setupArgs());
expect(result.validation).toEqual({ ok: true, value: undefined });
// Config files should still be written
// Config file should still be written
expect(result.configPath).toBeTruthy();
expect(result.envPath).toBeTruthy();
});
test("includes validation failure — config still saved", async () => {
@@ -143,8 +142,7 @@ describe("cmdSetup with validation", () => {
expect(result.validation).toBeDefined();
expect((result.validation as { ok: boolean }).ok).toBe(false);
// Config files should still be written despite validation failure
// Config file should still be written despite validation failure
expect(result.configPath).toBeTruthy();
expect(result.envPath).toBeTruthy();
});
});
@@ -0,0 +1,140 @@
import { execFileSync } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, test } from "vitest";
const __dirname = dirname(fileURLToPath(import.meta.url));
import {
cmdSkillActor,
cmdSkillAdapter,
cmdSkillArchitecture,
cmdSkillAuthor,
cmdSkillCli,
cmdSkillDeveloper,
cmdSkillList,
cmdSkillModerator,
cmdSkillUser,
cmdSkillYaml,
} from "../commands/skill.js";
describe("skill commands", () => {
test("skill list returns all skill names", () => {
const result = cmdSkillList();
expect(result).toBeInstanceOf(Array);
expect(result).toContain("cli");
expect(result).toContain("architecture");
expect(result).toContain("yaml");
expect(result).toContain("moderator");
expect(result).toContain("actor");
expect(result).toContain("user");
expect(result).toContain("author");
expect(result).toContain("developer");
expect(result).toContain("adapter");
for (const name of result) {
expect(name).toMatch(/^\S+$/);
}
});
test("skill architecture returns non-empty markdown string", () => {
const result = cmdSkillArchitecture();
expect(typeof result).toBe("string");
expect(result).toContain("CAS");
expect(result).toContain("Thread");
expect(result).toContain("Workflow");
expect(result).toContain("Step");
expect(result.length).toBeGreaterThan(200);
});
test("skill yaml returns non-empty markdown string", () => {
const result = cmdSkillYaml();
expect(typeof result).toBe("string");
expect(result).toContain("roles");
expect(result).toContain("graph");
expect(result).toContain("frontmatter");
expect(result.length).toBeGreaterThan(200);
});
test("skill moderator returns non-empty markdown string", () => {
const result = cmdSkillModerator();
expect(typeof result).toBe("string");
expect(result).toContain("routing");
expect(result).toContain("status");
expect(result.length).toBeGreaterThan(200);
// Check for edge or graph
expect(result).toMatch(/edge|graph/i);
});
test("skill cli returns CLI reference markdown", () => {
const result = cmdSkillCli();
expect(typeof result).toBe("string");
expect(result).toContain("uwf");
});
test("skill actor returns non-empty markdown string", () => {
const result = cmdSkillActor();
expect(typeof result).toBe("string");
expect(result).toContain("frontmatter");
expect(result).toContain("CAS");
expect(result).toContain("status");
expect(result.length).toBeGreaterThan(200);
});
test("skill user returns non-empty markdown string", () => {
const result = cmdSkillUser();
expect(typeof result).toBe("string");
expect(result).toContain("uwf");
expect(result).toContain("thread");
expect(result).toContain("workflow");
expect(result).toContain("Quick Start");
expect(result.length).toBeGreaterThan(500);
});
test("skill author returns non-empty markdown string", () => {
const result = cmdSkillAuthor();
expect(typeof result).toBe("string");
expect(result).toContain("frontmatter");
expect(result).toContain("graph");
expect(result).toContain("$START");
expect(result).toContain("$END");
expect(result).toContain("$status");
expect(result.length).toBeGreaterThan(500);
});
test("skill developer returns non-empty markdown string", () => {
const result = cmdSkillDeveloper();
expect(typeof result).toBe("string");
expect(result).toContain("Monorepo");
expect(result).toContain("CAS");
expect(result).toContain("Biome");
expect(result.length).toBeGreaterThan(500);
});
test("skill adapter returns non-empty markdown string", () => {
const result = cmdSkillAdapter();
expect(typeof result).toBe("string");
expect(result).toContain("createAgent");
expect(result).toContain("AgentContext");
expect(result).toContain("frontmatter");
expect(result.length).toBeGreaterThan(500);
});
test("skill help subcommand is suppressed", () => {
const output = execFileSync("bun", ["src/cli.ts", "skill", "--help"], {
cwd: join(__dirname, "..", ".."),
encoding: "utf-8",
env: { ...process.env, PATH: `/opt/homebrew/bin:${process.env.PATH}` },
});
expect(output).not.toMatch(/help\s+\[command\]/i);
expect(output).toContain("cli");
expect(output).toContain("architecture");
expect(output).toContain("yaml");
expect(output).toContain("moderator");
expect(output).toContain("actor");
expect(output).toContain("user");
expect(output).toContain("author");
expect(output).toContain("developer");
expect(output).toContain("adapter");
expect(output).toContain("list");
});
});
@@ -0,0 +1,100 @@
import { describe, expect, test } from "vitest";
/**
* B-group tests: validate JSON parsing logic used by spawnAgent.
*
* We test the parsing logic inline since spawnAgent is a private function.
* These tests verify the contract: last line of stdout must be valid JSON
* with a valid stepHash CasRef.
*/
const CASREF_PATTERN = /^[0-9A-HJ-NP-TV-Z]{13}$/;
function isCasRef(s: string): boolean {
return CASREF_PATTERN.test(s);
}
type AdapterOutput = {
stepHash: string;
detailHash: string;
role: string;
frontmatter: Record<string, unknown>;
body: string;
startedAtMs: number;
completedAtMs: number;
};
function parseAgentStdout(stdout: string): AdapterOutput {
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
throw new Error(`agent stdout last line is not valid JSON: ${line || "(empty)"}`);
}
const obj = parsed as Record<string, unknown>;
if (
typeof obj !== "object" ||
obj === null ||
typeof obj.stepHash !== "string" ||
!isCasRef(obj.stepHash as string)
) {
throw new Error(`agent stdout JSON missing valid stepHash: ${line}`);
}
return obj as unknown as AdapterOutput;
}
const VALID_OUTPUT: AdapterOutput = {
stepHash: "0123456789ABC",
detailHash: "DEFGH12345678",
role: "planner",
frontmatter: { $status: "ready", plan: "somehash" },
body: "Plan body",
startedAtMs: 1000,
completedAtMs: 2000,
};
describe("spawnAgent JSON parsing", () => {
test("B1. parses valid JSON from agent stdout", () => {
const stdout = JSON.stringify(VALID_OUTPUT) + "\n";
const result = parseAgentStdout(stdout);
expect(result.stepHash).toBe("0123456789ABC");
expect(result.detailHash).toBe("DEFGH12345678");
expect(result.role).toBe("planner");
expect(result.frontmatter).toEqual({ $status: "ready", plan: "somehash" });
expect(result.body).toBe("Plan body");
expect(result.startedAtMs).toBe(1000);
expect(result.completedAtMs).toBe(2000);
});
test("B2. extracts stepHash for head pointer", () => {
const stdout = JSON.stringify(VALID_OUTPUT) + "\n";
const result = parseAgentStdout(stdout);
expect(result.stepHash).toBe("0123456789ABC");
expect(isCasRef(result.stepHash)).toBe(true);
});
test("B3. handles debug lines before JSON", () => {
const debugLines = "[debug] loading context...\n[debug] running agent...\n";
const stdout = debugLines + JSON.stringify(VALID_OUTPUT) + "\n";
const result = parseAgentStdout(stdout);
expect(result.stepHash).toBe("0123456789ABC");
});
test("B4. rejects non-JSON last line", () => {
const stdout = "not-json-at-all\n";
expect(() => parseAgentStdout(stdout)).toThrow("not valid JSON");
});
test("B5. rejects JSON missing stepHash", () => {
const incomplete = { detailHash: "DEFGH12345678", role: "planner" };
const stdout = JSON.stringify(incomplete) + "\n";
expect(() => parseAgentStdout(stdout)).toThrow("missing valid stepHash");
});
test("B6. rejects JSON with invalid stepHash", () => {
const bad = { ...VALID_OUTPUT, stepHash: "not-a-hash" };
const stdout = JSON.stringify(bad) + "\n";
expect(() => parseAgentStdout(stdout)).toThrow("missing valid stepHash");
});
});
@@ -453,7 +453,78 @@ describe("step read", () => {
expect(markdown).not.toContain("## Turn");
});
test("test 6: turn content with special characters", async () => {
test("test 6: displays role and tool calls in turn body", 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 turnHash = await store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content: "",
toolCalls: [{ name: "terminal", args: '{"command":"echo hi"}' }],
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-hermes",
startedAtMs: 1000000000000,
completedAtMs: 1000000005000,
});
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
expect(markdown).toContain("**Turn role:** assistant");
expect(markdown).toContain("**terminal**");
expect(markdown).toContain('{"command":"echo hi"}');
});
test("test 7: turn content with special characters", async () => {
const casDir = join(tmpDir, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
@@ -0,0 +1,363 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, type Hash, type JSONSchema, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, StepNodePayload } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { cmdStepShow } from "../commands/step.js";
import { formatOutput } from "../format.js";
import { registerUwfSchemas } from "../schemas.js";
const TURN_SCHEMA: JSONSchema = {
title: "test-turn",
type: "object",
required: ["index", "role", "content"],
properties: {
index: { type: "integer" },
role: { type: "string", enum: ["assistant", "tool"] },
content: { type: "string" },
toolCalls: {
anyOf: [
{
type: "array",
items: {
type: "object",
required: ["name", "args"],
properties: {
name: { type: "string" },
args: { type: "string" },
},
additionalProperties: false,
},
},
{ type: "null" },
],
},
},
additionalProperties: false,
};
const DETAIL_SCHEMA: JSONSchema = {
title: "test-detail",
type: "object",
required: ["turns"],
properties: {
turns: {
type: "array",
items: { type: "string", format: "cas_ref" },
},
},
additionalProperties: false,
};
type TestSetup = {
store: ReturnType<typeof createFsStore>;
schemas: {
workflow: Hash;
startNode: Hash;
stepNode: Hash;
text: Hash;
};
turnType: Hash;
detailType: Hash;
};
async function setupTest(casDir: string): Promise<TestSetup> {
const store = createFsStore(casDir);
await bootstrap(store);
const schemas = await registerUwfSchemas(store);
const [turnType, detailType] = await Promise.all([
putSchema(store, TURN_SCHEMA),
putSchema(store, DETAIL_SCHEMA),
]);
return { store, schemas, turnType, detailType };
}
async function createTestStep(
setup: TestSetup,
turnPayloads: Array<{
index: number;
role: string;
content: string;
toolCalls: Array<{ name: string; args: string }> | null;
}>,
): Promise<CasRef> {
const { store, schemas, turnType, detailType } = setup;
// Create turn nodes
const turnHashes: CasRef[] = [];
for (const payload of turnPayloads) {
const turnHash = await store.put(turnType, payload);
turnHashes.push(turnHash);
}
// Create detail node
const detailHash = await store.put(detailType, { turns: turnHashes });
// Create dummy start node
const startHash = await store.put(schemas.startNode, {
workflow: "0000000000000" as CasRef,
prompt: "test prompt",
cwd: "/tmp",
});
// Create dummy output node
const outputHash = await store.put(schemas.text, { $status: "done" });
// Create step node
const stepPayload: StepNodePayload = {
prev: null,
start: startHash,
role: "test-role",
agent: "test-agent",
output: outputHash,
detail: detailHash,
edgePrompt: "",
startedAtMs: Date.now(),
completedAtMs: Date.now() + 1000,
cwd: "/tmp",
};
return store.put(schemas.stepNode, stepPayload);
}
describe("cmdStepShow JSON serialization", () => {
let testDir: string;
let casDir: string;
beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), "uwf-test-"));
casDir = join(testDir, "cas");
await mkdir(casDir, { recursive: true });
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
});
test("escapes newlines in tool call args", async () => {
const setup = await setupTest(casDir);
const stepHash = await createTestStep(setup, [
{
index: 0,
role: "assistant",
content: "Running command",
toolCalls: [
{
name: "Bash",
args: "echo 'line1'\necho 'line2'",
},
],
},
]);
const result = await cmdStepShow(testDir, stepHash);
const jsonOutput = formatOutput(result, "json");
expect(() => JSON.parse(jsonOutput)).not.toThrow();
expect(jsonOutput).toContain("\\n");
const parsed = JSON.parse(jsonOutput);
expect(parsed.turns[0].toolCalls[0].args).toContain("\n");
});
test("escapes tabs in tool call args", async () => {
const setup = await setupTest(casDir);
const stepHash = await createTestStep(setup, [
{
index: 0,
role: "assistant",
content: "",
toolCalls: [
{
name: "Bash",
args: "cat <<EOF\nfield1\tfield2\tfield3\nEOF",
},
],
},
]);
const result = await cmdStepShow(testDir, stepHash);
const jsonOutput = formatOutput(result, "json");
expect(() => JSON.parse(jsonOutput)).not.toThrow();
expect(jsonOutput).toContain("\\t");
});
test("escapes carriage returns", async () => {
const setup = await setupTest(casDir);
const stepHash = await createTestStep(setup, [
{
index: 0,
role: "assistant",
content: "Committing changes",
toolCalls: [
{
name: "Bash",
args: 'git commit -m "First line\r\nSecond line"',
},
],
},
]);
const result = await cmdStepShow(testDir, stepHash);
const jsonOutput = formatOutput(result, "json");
expect(() => JSON.parse(jsonOutput)).not.toThrow();
expect(jsonOutput).toContain("\\r\\n");
});
test("escapes backslashes and quotes", async () => {
const setup = await setupTest(casDir);
const stepHash = await createTestStep(setup, [
{
index: 0,
role: "assistant",
content: "",
toolCalls: [
{
name: "Bash",
args: 'echo "He said \\"hello\\""',
},
],
},
]);
const result = await cmdStepShow(testDir, stepHash);
const jsonOutput = formatOutput(result, "json");
expect(() => JSON.parse(jsonOutput)).not.toThrow();
const parsed = JSON.parse(jsonOutput);
expect(parsed.turns).toBeDefined();
});
test("handles Unicode control characters", async () => {
const setup = await setupTest(casDir);
const stepHash = await createTestStep(setup, [
{
index: 0,
role: "assistant",
content: "",
toolCalls: [
{
name: "Bash",
args: "echo '\u0001\u001F'",
},
],
},
]);
const result = await cmdStepShow(testDir, stepHash);
const jsonOutput = formatOutput(result, "json");
expect(() => JSON.parse(jsonOutput)).not.toThrow();
});
test("handles nested CAS refs with control characters", async () => {
const setup = await setupTest(casDir);
const stepHash = await createTestStep(setup, [
{
index: 0,
role: "assistant",
content: "First turn\nwith newline",
toolCalls: [
{
name: "Bash",
args: "cmd1\nline2",
},
],
},
{
index: 1,
role: "assistant",
content: "Second turn\twith tab",
toolCalls: null,
},
]);
const result = await cmdStepShow(testDir, stepHash);
const jsonOutput = formatOutput(result, "json");
expect(() => JSON.parse(jsonOutput)).not.toThrow();
const parsed = JSON.parse(jsonOutput);
expect(parsed.turns).toHaveLength(2);
});
test("YAML output format is unaffected", async () => {
const setup = await setupTest(casDir);
const stepHash = await createTestStep(setup, [
{
index: 0,
role: "assistant",
content: "Running command",
toolCalls: [
{
name: "Bash",
args: "echo 'line1'\necho 'line2'",
},
],
},
]);
const result = await cmdStepShow(testDir, stepHash);
const yamlOutput = formatOutput(result, "yaml");
expect(yamlOutput).toContain("turns:");
expect(yamlOutput.length).toBeGreaterThan(0);
});
test("handles empty and null values", async () => {
const setup = await setupTest(casDir);
const stepHash = await createTestStep(setup, [
{
index: 0,
role: "assistant",
content: "",
toolCalls: null,
},
]);
const result = await cmdStepShow(testDir, stepHash);
const jsonOutput = formatOutput(result, "json");
expect(() => JSON.parse(jsonOutput)).not.toThrow();
const parsed = JSON.parse(jsonOutput);
expect(parsed.turns).toBeDefined();
});
test("handles large step with multiple tool calls", async () => {
const setup = await setupTest(casDir);
const turns = [];
for (let i = 0; i < 25; i++) {
turns.push({
index: i,
role: "assistant" as const,
content: `Turn ${i}\nwith newline`,
toolCalls: [
{
name: "Bash",
args: `command${i}\nline2\tfield${i}`,
},
{
name: "Read",
args: `/path/to/file${i}`,
},
],
});
}
const stepHash = await createTestStep(setup, turns);
const startTime = Date.now();
const result = await cmdStepShow(testDir, stepHash);
const jsonOutput = formatOutput(result, "json");
const duration = Date.now() - startTime;
expect(duration).toBeLessThan(2000);
expect(() => JSON.parse(jsonOutput)).not.toThrow();
const parsed = JSON.parse(jsonOutput);
expect(parsed.turns).toHaveLength(25);
});
});
@@ -85,6 +85,7 @@ describe("protocol types", () => {
edgePrompt: "",
startedAtMs: 1000,
completedAtMs: 2000,
cwd: "/test/path",
};
expect(record.startedAtMs).toBe(1000);
expect(record.completedAtMs).toBe(2000);
@@ -239,8 +240,8 @@ describe("thread read timing", () => {
},
},
graph: {
$START: { _: { role: "worker", prompt: "go" } },
worker: { _: { role: "$END", prompt: "" } },
$START: { _: { role: "worker", prompt: "go", location: null } },
worker: { _: { role: "$END", prompt: "", location: null } },
},
});
@@ -305,8 +306,8 @@ describe("thread read timing", () => {
},
},
graph: {
$START: { _: { role: "worker", prompt: "go" } },
worker: { _: { role: "$END", prompt: "" } },
$START: { _: { role: "worker", prompt: "go", location: null } },
worker: { _: { role: "$END", prompt: "", location: null } },
},
});
@@ -0,0 +1,85 @@
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { appendThreadHistory, loadThreadHistory } from "../store.js";
describe("thread cancel status", () => {
test("cancelled history entry has reason 'cancelled'", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-"));
const threadId = "01JTEST000000000000CANCEL1" as ThreadId;
await appendThreadHistory(tmpDir, {
thread: threadId,
workflow: "test-workflow",
head: "test-head-hash" as CasRef,
completedAt: Date.now(),
reason: "cancelled",
});
const history = await loadThreadHistory(tmpDir);
expect(history).toHaveLength(1);
expect(history[0]?.reason).toBe("cancelled");
});
test("completed history entry has reason 'completed'", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-"));
const threadId = "01JTEST000000000000CANCEL2" as ThreadId;
await appendThreadHistory(tmpDir, {
thread: threadId,
workflow: "test-workflow",
head: "test-head-hash" as CasRef,
completedAt: Date.now(),
reason: "completed",
});
const history = await loadThreadHistory(tmpDir);
expect(history).toHaveLength(1);
expect(history[0]?.reason).toBe("completed");
});
test("legacy history entry without reason parses as null", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-"));
const threadId = "01JTEST000000000000CANCEL3" as ThreadId;
// Simulate legacy entry without reason field
await appendThreadHistory(tmpDir, {
thread: threadId,
workflow: "test-workflow",
head: "test-head-hash" as CasRef,
completedAt: Date.now(),
reason: null,
});
const history = await loadThreadHistory(tmpDir);
expect(history).toHaveLength(1);
expect(history[0]?.reason).toBeNull();
});
test("mixed completed and cancelled entries preserve distinct reasons", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "uwf-cancel-test-"));
await appendThreadHistory(tmpDir, {
thread: "01JTEST000000000000CANCEL4" as ThreadId,
workflow: "test-workflow",
head: "head1" as CasRef,
completedAt: Date.now(),
reason: "completed",
});
await appendThreadHistory(tmpDir, {
thread: "01JTEST000000000000CANCEL5" as ThreadId,
workflow: "test-workflow",
head: "head2" as CasRef,
completedAt: Date.now(),
reason: "cancelled",
});
const history = await loadThreadHistory(tmpDir);
expect(history).toHaveLength(2);
expect(history[0]?.reason).toBe("completed");
expect(history[1]?.reason).toBe("cancelled");
});
});
@@ -74,6 +74,7 @@ async function completeThread(
workflow: workflowHash,
head: headHash,
completedAt: Date.now(),
reason: null,
});
}
@@ -0,0 +1,174 @@
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasRef, StartNodePayload, ThreadId } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { cmdThreadStart } from "../commands/thread.js";
import { createUwfStore } from "../store.js";
describe("Thread and edge location integration", () => {
let tmpDir: string;
let storageRoot: string;
async function setupTestEnv() {
tmpDir = join(tmpdir(), `uwf-test-location-${Date.now()}`);
storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
}
async function teardown() {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true });
}
}
test("thread start captures cwd in StartNode", async () => {
await setupTestEnv();
const workflowYaml = `
name: test-location
description: Test workflow for location feature
roles:
planner:
description: Plans the work
goal: Plan implementation
capabilities: ["planning"]
procedure: Plan
output: |
$status: "ready"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string }
graph:
$START:
_:
role: planner
prompt: "Plan the work"
location: null
planner:
_:
role: $END
prompt: "Done"
location: null
`;
const workflowPath = join(tmpDir, "test-location.yaml");
await writeFile(workflowPath, workflowYaml, "utf8");
const testCwd = "/test/project/path";
const result = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir, testCwd);
expect(result.thread).toBeDefined();
expect(result.workflow).toBeDefined();
// Verify StartNode has the cwd field
const uwf = await createUwfStore(storageRoot);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
const headHash = index[result.thread as ThreadId];
expect(headHash).toBeDefined();
const startNode = uwf.store.get(headHash as CasRef);
expect(startNode).not.toBe(null);
expect(startNode?.type).toBe(uwf.schemas.startNode);
const startPayload = startNode?.payload as StartNodePayload;
expect(startPayload.cwd).toBe(testCwd);
await teardown();
});
test("thread start validates cwd is absolute path", async () => {
await setupTestEnv();
const workflowYaml = `
name: test-location
description: Test workflow
roles:
planner:
description: Plans
goal: Plan
capabilities: ["planning"]
procedure: Plan
output: |
$status: "ready"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string }
graph:
$START:
_:
role: planner
prompt: "Plan"
location: null
planner:
_:
role: $END
prompt: "Done"
location: null
`;
const workflowPath = join(tmpDir, "test-location.yaml");
await writeFile(workflowPath, workflowYaml, "utf8");
// Relative path should fail (process.exit is wrapped by vitest)
await expect(
cmdThreadStart(storageRoot, workflowPath, "test", tmpDir, "relative/path"),
).rejects.toThrow();
await teardown();
});
test("thread start uses process.cwd() as default", async () => {
await setupTestEnv();
const workflowYaml = `
name: test-default-cwd
description: Test default cwd
roles:
planner:
description: Plans
goal: Plan
capabilities: ["planning"]
procedure: Plan
output: |
$status: "ready"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string }
graph:
$START:
_:
role: planner
prompt: "Plan"
location: null
planner:
_:
role: $END
prompt: "Done"
location: null
`;
const workflowPath = join(tmpDir, "test-default-cwd.yaml");
await writeFile(workflowPath, workflowYaml, "utf8");
const result = await cmdThreadStart(storageRoot, workflowPath, "test", tmpDir);
const uwf = await createUwfStore(storageRoot);
const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot));
const headHash = index[result.thread as ThreadId];
const startNode = uwf.store.get(headHash as CasRef);
const startPayload = startNode?.payload as StartNodePayload;
// Should default to process.cwd()
expect(startPayload.cwd).toBe(process.cwd());
await teardown();
});
});
@@ -0,0 +1,227 @@
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ThreadId } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { createMarker, deleteMarker } from "../background/index.js";
import { cmdThreadShow, cmdThreadStart } from "../commands/thread.js";
import { appendThreadHistory, loadThreadsIndex } from "../store.js";
const TEST_WORKFLOW_YAML = `
name: test-status
description: Test workflow for status field
roles:
planner:
description: Plans the work
goal: Plan implementation
capabilities: ["planning"]
procedure: Plan
output: |
$status: "ready"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string }
graph:
$START:
_:
role: planner
prompt: "Plan the work"
location: null
planner:
_:
role: $END
prompt: "Done"
location: null
`;
describe("thread show status field", () => {
let tmpDir: string;
let storageRoot: string;
async function setupTestEnv() {
tmpDir = join(tmpdir(), `uwf-test-status-${Date.now()}`);
storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
}
async function teardown() {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true });
}
}
test("active idle thread shows status 'idle'", async () => {
await setupTestEnv();
const workflowPath = join(tmpDir, "test-status.yaml");
await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8");
// Create a thread
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
const threadId = startResult.thread as ThreadId;
// Show the thread (should be idle)
const result = await cmdThreadShow(storageRoot, threadId);
expect(result.status).toBe("idle");
expect(result.done).toBe(false);
expect(result.background).toBe(null);
expect(result.thread).toBe(threadId);
await teardown();
});
test("active running thread shows status 'running'", async () => {
await setupTestEnv();
const workflowPath = join(tmpDir, "test-status.yaml");
await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8");
// Create a thread
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
const threadId = startResult.thread as ThreadId;
const workflow = startResult.workflow;
// Create a running marker
await createMarker(storageRoot, {
thread: threadId,
workflow,
pid: process.pid,
startedAt: Date.now(),
});
try {
const result = await cmdThreadShow(storageRoot, threadId);
expect(result.status).toBe("running");
expect(result.done).toBe(false);
expect(result.background).toBe(null);
expect(result.thread).toBe(threadId);
} finally {
// Cleanup: delete marker
await deleteMarker(storageRoot, threadId);
await teardown();
}
});
test("completed thread shows status 'completed'", async () => {
await setupTestEnv();
const workflowPath = join(tmpDir, "test-status.yaml");
await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8");
// Create a thread
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
const threadId = startResult.thread as ThreadId;
const workflow = startResult.workflow;
// Get the head hash before moving to history
const index = await loadThreadsIndex(storageRoot);
const head = index[threadId];
if (!head) throw new Error("Thread not found in index");
// Move thread to history with reason 'completed'
const { saveThreadsIndex } = await import("../store.js");
const newIndex = { ...index };
delete newIndex[threadId];
await saveThreadsIndex(storageRoot, newIndex);
await appendThreadHistory(storageRoot, {
thread: threadId,
workflow,
head,
completedAt: Date.now(),
reason: "completed",
});
const result = await cmdThreadShow(storageRoot, threadId);
expect(result.status).toBe("completed");
expect(result.done).toBe(true);
expect(result.background).toBe(null);
expect(result.thread).toBe(threadId);
await teardown();
});
test("cancelled thread shows status 'cancelled'", async () => {
await setupTestEnv();
const workflowPath = join(tmpDir, "test-status.yaml");
await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8");
// Create a thread
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
const threadId = startResult.thread as ThreadId;
const workflow = startResult.workflow;
// Get the head hash before moving to history
const index = await loadThreadsIndex(storageRoot);
const head = index[threadId];
if (!head) throw new Error("Thread not found in index");
// Move thread to history with reason 'cancelled'
const { saveThreadsIndex } = await import("../store.js");
const newIndex = { ...index };
delete newIndex[threadId];
await saveThreadsIndex(storageRoot, newIndex);
await appendThreadHistory(storageRoot, {
thread: threadId,
workflow,
head,
completedAt: Date.now(),
reason: "cancelled",
});
const result = await cmdThreadShow(storageRoot, threadId);
expect(result.status).toBe("cancelled");
expect(result.done).toBe(true);
expect(result.background).toBe(null);
expect(result.thread).toBe(threadId);
await teardown();
});
test("legacy completed thread without reason shows status 'completed'", async () => {
await setupTestEnv();
const workflowPath = join(tmpDir, "test-status.yaml");
await writeFile(workflowPath, TEST_WORKFLOW_YAML, "utf8");
// Create a thread
const startResult = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
const threadId = startResult.thread as ThreadId;
const workflow = startResult.workflow;
// Get the head hash before moving to history
const index = await loadThreadsIndex(storageRoot);
const head = index[threadId];
if (!head) throw new Error("Thread not found in index");
// Move thread to history with reason null (legacy format)
const { saveThreadsIndex } = await import("../store.js");
const newIndex = { ...index };
delete newIndex[threadId];
await saveThreadsIndex(storageRoot, newIndex);
await appendThreadHistory(storageRoot, {
thread: threadId,
workflow,
head,
completedAt: Date.now(),
reason: null,
});
const result = await cmdThreadShow(storageRoot, threadId);
expect(result.status).toBe("completed");
expect(result.done).toBe(true);
expect(result.background).toBe(null);
await teardown();
});
});
@@ -0,0 +1,148 @@
import { execFileSync } from "node:child_process";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CasRef, StartNodePayload, ThreadId } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { cmdThreadStart } from "../commands/thread.js";
import { createUwfStore, loadThreadsIndex } from "../store.js";
describe("thread start --cwd CLI option", () => {
let tmpDir: string;
let storageRoot: string;
async function setupTestEnv() {
tmpDir = join(tmpdir(), `uwf-test-cwd-cli-${Date.now()}`);
storageRoot = join(tmpDir, "storage");
await mkdir(storageRoot, { recursive: true });
}
async function teardown() {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true });
}
}
async function createTestWorkflow(): Promise<string> {
const workflowYaml = `
name: test-cwd-cli
description: Test workflow for CLI cwd option
roles:
planner:
description: Plans the work
goal: Plan implementation
capabilities: ["planning"]
procedure: Plan
output: |
$status: "ready"
frontmatter:
type: object
required: ["$status"]
properties:
$status: { type: string }
graph:
$START:
_:
role: planner
prompt: "Plan the work"
location: null
planner:
_:
role: $END
prompt: "Done"
location: null
`;
const workflowPath = join(tmpDir, "test-cwd-cli.yaml");
await writeFile(workflowPath, workflowYaml, "utf8");
return workflowPath;
}
async function getStartNodeCwd(threadId: string): Promise<string> {
const uwf = await createUwfStore(storageRoot);
const index = await loadThreadsIndex(storageRoot);
const headHash = index[threadId as ThreadId];
expect(headHash).toBeDefined();
const startNode = uwf.store.get(headHash as CasRef);
expect(startNode).not.toBe(null);
expect(startNode?.type).toBe(uwf.schemas.startNode);
const startPayload = startNode?.payload as StartNodePayload;
return startPayload.cwd;
}
test("thread start with custom cwd via cmdThreadStart", async () => {
await setupTestEnv();
const workflowPath = await createTestWorkflow();
const testCwd = "/test/custom/path";
const result = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir, testCwd);
expect(result.thread).toBeDefined();
const actualCwd = await getStartNodeCwd(result.thread);
expect(actualCwd).toBe(testCwd);
await teardown();
});
test("thread start without cwd defaults to process.cwd()", async () => {
await setupTestEnv();
const workflowPath = await createTestWorkflow();
// Call without cwd parameter (it defaults to process.cwd())
const result = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir);
expect(result.thread).toBeDefined();
const actualCwd = await getStartNodeCwd(result.thread);
expect(actualCwd).toBe(process.cwd());
await teardown();
});
test("thread start with relative path fails", async () => {
await setupTestEnv();
const workflowPath = await createTestWorkflow();
await expect(
cmdThreadStart(storageRoot, workflowPath, "test", tmpDir, "relative/path"),
).rejects.toThrow();
await teardown();
});
test("CLI accepts --cwd option without error", async () => {
await setupTestEnv();
const workflowPath = await createTestWorkflow();
const testCwd = "/test/cli/path";
const uwfBin = join(process.cwd(), "dist", "cli.js");
// Register the workflow
execFileSync("node", [uwfBin, "workflow", "add", workflowPath], {
env: { ...process.env, UWF_STORAGE_ROOT: storageRoot },
encoding: "utf8",
});
// Verify CLI accepts --cwd option (no error thrown)
const output = execFileSync(
"node",
[uwfBin, "thread", "start", "test-cwd-cli", "-p", "test prompt", "--cwd", testCwd],
{
env: { ...process.env, UWF_STORAGE_ROOT: storageRoot },
encoding: "utf8",
},
);
const result = JSON.parse(output);
expect(result.thread).toBeDefined();
expect(result.workflow).toBeDefined();
// The fact that we got here without throwing means CLI accepted the --cwd option
// The actual cwd functionality is tested by the other tests using cmdThreadStart directly
await teardown();
});
});
@@ -758,6 +758,7 @@ describe("cmdStepList with completed threads", () => {
workflow: workflowHash,
head: step2Hash,
completedAt: Date.now(),
reason: null,
});
const result = await cmdStepList(tmpDir, threadId);
@@ -886,6 +887,7 @@ describe("cmdStepShow with completed threads", () => {
workflow: workflowHash,
head: stepHash,
completedAt: Date.now(),
reason: null,
});
const result = await cmdStepShow(tmpDir, stepHash);
@@ -949,6 +951,7 @@ describe("cmdThreadRead with completed threads", () => {
workflow: workflowHash,
head: stepHash,
completedAt: Date.now(),
reason: null,
});
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
@@ -1011,6 +1014,7 @@ describe("cmdThreadRead with completed threads", () => {
workflow: workflowHash,
head: step3Hash,
completedAt: Date.now(),
reason: null,
});
const markdown = await cmdThreadRead(
@@ -51,11 +51,11 @@ function makeWorkflow(overrides?: Partial<WorkflowPayload>): WorkflowPayload {
},
},
graph: {
$START: { _: { role: "writer", prompt: "Begin writing" } },
writer: { _: { role: "reviewer", prompt: "Review this: {{{plan}}}" } },
$START: { _: { role: "writer", prompt: "Begin writing", location: null } },
writer: { _: { role: "reviewer", prompt: "Review this: {{{plan}}}", location: null } },
reviewer: {
approved: { role: "$END", prompt: "Done: {{{summary}}}" },
rejected: { role: "writer", prompt: "Fix: {{{reason}}}" },
approved: { role: "$END", prompt: "Done: {{{summary}}}", location: null },
rejected: { role: "writer", prompt: "Fix: {{{reason}}}", location: null },
},
},
};
@@ -67,7 +67,7 @@ function makeWorkflow(overrides?: Partial<WorkflowPayload>): WorkflowPayload {
describe("Suite 1: Role Reference Integrity", () => {
test("1.1 graph references unknown role", () => {
const wf = makeWorkflow();
wf.graph.nonexistent = { _: { role: "$END", prompt: "done" } };
wf.graph.nonexistent = { _: { role: "$END", prompt: "done", location: null } };
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes('unknown role "nonexistent"'))).toBe(true);
});
@@ -138,8 +138,8 @@ describe("Suite 2: Graph Structure", () => {
test("2.2 $START has multiple status keys", () => {
const wf = makeWorkflow();
wf.graph.$START = {
_: { role: "writer", prompt: "Begin" },
other: { role: "reviewer", prompt: "Also" },
_: { role: "writer", prompt: "Begin", location: null },
other: { role: "reviewer", prompt: "Also", location: null },
};
const errors = validateWorkflow(wf);
expect(
@@ -149,7 +149,7 @@ describe("Suite 2: Graph Structure", () => {
test("2.3 $START edge uses non-_ status", () => {
const wf = makeWorkflow();
wf.graph.$START = { ready: { role: "writer", prompt: "Begin" } };
wf.graph.$START = { ready: { role: "writer", prompt: "Begin", location: null } };
const errors = validateWorkflow(wf);
expect(
errors.some((e) => e.includes('$START must have exactly one edge with status "_"')),
@@ -158,7 +158,7 @@ describe("Suite 2: Graph Structure", () => {
test("2.4 $END has outgoing edges", () => {
const wf = makeWorkflow();
wf.graph.$END = { _: { role: "writer", prompt: "Loop" } };
wf.graph.$END = { _: { role: "writer", prompt: "Loop", location: null } };
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("$END must not have outgoing edges"))).toBe(true);
});
@@ -177,7 +177,7 @@ describe("Suite 2: Graph Structure", () => {
required: ["$status"],
} as unknown as string,
};
wf.graph.isolated = { _: { role: "$END", prompt: "done" } };
wf.graph.isolated = { _: { role: "$END", prompt: "done", location: null } };
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes('role "isolated" is not reachable from $START'))).toBe(
true,
@@ -186,7 +186,7 @@ describe("Suite 2: Graph Structure", () => {
test("2.6 edge target references invalid role", () => {
const wf = makeWorkflow();
wf.graph.writer = { _: { role: "ghost", prompt: "Go to ghost" } };
wf.graph.writer = { _: { role: "ghost", prompt: "Go to ghost", location: null } };
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes('unknown target role "ghost"'))).toBe(true);
});
@@ -196,8 +196,8 @@ describe("Suite 3: Status-Edge Consistency", () => {
test("3.1 single-exit role with multiple graph keys", () => {
const wf = makeWorkflow();
wf.graph.writer = {
_: { role: "reviewer", prompt: "Review" },
extra: { role: "$END", prompt: "Done" },
_: { role: "reviewer", prompt: "Review", location: null },
extra: { role: "$END", prompt: "Done", location: null },
};
const errors = validateWorkflow(wf);
expect(
@@ -209,7 +209,7 @@ describe("Suite 3: Status-Edge Consistency", () => {
test("3.2 single-exit role missing _ key", () => {
const wf = makeWorkflow();
wf.graph.writer = { done: { role: "reviewer", prompt: "Review" } };
wf.graph.writer = { done: { role: "reviewer", prompt: "Review", location: null } };
const errors = validateWorkflow(wf);
expect(
errors.some((e) => e.includes('role "writer" is single-exit but graph has no "_" key')),
@@ -219,9 +219,9 @@ describe("Suite 3: Status-Edge Consistency", () => {
test("3.3 multi-exit role with extra statuses", () => {
const wf = makeWorkflow();
wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done" },
rejected: { role: "writer", prompt: "Fix" },
timeout: { role: "$END", prompt: "Timed out" },
approved: { role: "$END", prompt: "Done", location: null },
rejected: { role: "writer", prompt: "Fix", location: null },
timeout: { role: "$END", prompt: "Timed out", location: null },
};
const errors = validateWorkflow(wf);
expect(
@@ -232,7 +232,7 @@ describe("Suite 3: Status-Edge Consistency", () => {
test("3.4 multi-exit role missing a status", () => {
const wf = makeWorkflow();
wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done" },
approved: { role: "$END", prompt: "Done", location: null },
};
const errors = validateWorkflow(wf);
expect(
@@ -242,7 +242,7 @@ describe("Suite 3: Status-Edge Consistency", () => {
test("3.5 multi-exit role with _ key", () => {
const wf = makeWorkflow();
wf.graph.reviewer = { _: { role: "$END", prompt: "Done" } };
wf.graph.reviewer = { _: { role: "$END", prompt: "Done", location: null } };
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes('role "reviewer" is multi-exit but graph uses "_"'))).toBe(
true,
@@ -250,10 +250,114 @@ describe("Suite 3: Status-Edge Consistency", () => {
});
});
describe("Suite 3b: Enum-Based Multi-Exit", () => {
test("3b.1 enum multi-exit passes with matching graph keys", () => {
const wf = makeWorkflow();
wf.roles.reviewer = {
...wf.roles.reviewer,
frontmatter: {
type: "object",
properties: {
$status: { enum: ["approved", "rejected"] },
comments: { type: "string" },
},
required: ["$status", "comments"],
} as unknown as string,
};
wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done", location: null },
rejected: { role: "writer", prompt: "Fix: {{{comments}}}", location: null },
};
const errors = validateWorkflow(wf);
expect(errors).toEqual([]);
});
test("3b.2 enum multi-exit with extra graph key", () => {
const wf = makeWorkflow();
wf.roles.reviewer = {
...wf.roles.reviewer,
frontmatter: {
type: "object",
properties: {
$status: { enum: ["approved", "rejected"] },
comments: { type: "string" },
},
required: ["$status", "comments"],
} as unknown as string,
};
wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done", location: null },
rejected: { role: "writer", prompt: "Fix", location: null },
timeout: { role: "$END", prompt: "Timed out", location: null },
};
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("extra status keys: timeout"))).toBe(true);
});
test("3b.3 enum multi-exit with missing graph key", () => {
const wf = makeWorkflow();
wf.roles.reviewer = {
...wf.roles.reviewer,
frontmatter: {
type: "object",
properties: {
$status: { enum: ["approved", "rejected"] },
comments: { type: "string" },
},
required: ["$status", "comments"],
} as unknown as string,
};
wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done", location: null },
};
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("missing status keys: rejected"))).toBe(true);
});
test("3b.4 enum with single value (not multi-exit) treated as single-exit", () => {
const wf = makeWorkflow();
wf.roles.writer = {
...wf.roles.writer,
frontmatter: {
type: "object",
properties: {
$status: { enum: ["_"] },
plan: { type: "string" },
},
required: ["$status", "plan"],
} as unknown as string,
};
wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{plan}}}", location: null } };
const errors = validateWorkflow(wf);
expect(errors).toEqual([]);
});
test("3b.5 enum multi-exit mustache var not in frontmatter", () => {
const wf = makeWorkflow();
wf.roles.reviewer = {
...wf.roles.reviewer,
frontmatter: {
type: "object",
properties: {
$status: { enum: ["approved", "rejected"] },
comments: { type: "string" },
},
required: ["$status", "comments"],
} as unknown as string,
};
wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done: {{{nonexistent}}}", location: null },
rejected: { role: "writer", prompt: "Fix: {{{comments}}}", location: null },
};
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("nonexistent") && e.includes("not found"))).toBe(true);
});
});
describe("Suite 4: Mustache Template Variable Existence", () => {
test("4.1 prompt references nonexistent variable (single-exit)", () => {
const wf = makeWorkflow();
wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{branch}}}" } };
wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{branch}}}", location: null } };
const errors = validateWorkflow(wf);
expect(
errors.some((e) =>
@@ -265,8 +369,8 @@ describe("Suite 4: Mustache Template Variable Existence", () => {
test("4.2 prompt references nonexistent variable (multi-exit)", () => {
const wf = makeWorkflow();
wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done: {{{branch}}}" },
rejected: { role: "writer", prompt: "Fix: {{{reason}}}" },
approved: { role: "$END", prompt: "Done: {{{branch}}}", location: null },
rejected: { role: "writer", prompt: "Fix: {{{reason}}}", location: null },
};
const errors = validateWorkflow(wf);
expect(
@@ -284,7 +388,7 @@ describe("Suite 4: Mustache Template Variable Existence", () => {
test("4.4 $status variable is always valid", () => {
const wf = makeWorkflow();
wf.graph.writer = { _: { role: "reviewer", prompt: "Status: {{$status}}" } };
wf.graph.writer = { _: { role: "reviewer", prompt: "Status: {{$status}}", location: null } };
const errors = validateWorkflow(wf);
expect(errors).toEqual([]);
});
@@ -357,9 +461,9 @@ describe("Suite 6: Multiple Errors Collection", () => {
} as unknown as string,
};
// unknown graph reference
wf.graph.nonexistent = { _: { role: "$END", prompt: "done" } };
wf.graph.nonexistent = { _: { role: "$END", prompt: "done", location: null } };
// bad mustache var
wf.graph.writer = { _: { role: "reviewer", prompt: "{{{badvar}}}" } };
wf.graph.writer = { _: { role: "reviewer", prompt: "{{{badvar}}}", location: null } };
const errors = validateWorkflow(wf);
expect(errors.length).toBeGreaterThanOrEqual(3);
});
@@ -31,12 +31,18 @@ function makeMinimalPayload(name: string, description: string): WorkflowPayload
capabilities: [],
procedure: "",
output: "",
frontmatter: { type: "0000000000000" } as unknown as CasRef,
frontmatter: {
type: "object",
properties: {
$status: { type: "string" },
},
required: ["$status"],
} as unknown as CasRef,
},
},
graph: {
$START: { _: { role: "worker", prompt: "start working" } },
worker: { _: { role: "$END", prompt: "done" } },
$START: { _: { role: "worker", prompt: "start working", location: null } },
worker: { _: { role: "$END", prompt: "done", location: null } },
},
};
}
+134 -12
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bun
#!/usr/bin/env node
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
import type { CasRef, ThreadId, ThreadStatus } from "@uncaged/workflow-protocol";
import { Command } from "commander";
import {
cmdCasGet,
@@ -13,9 +13,21 @@ import {
cmdCasSchemaList,
cmdCasWalk,
} from "./commands/cas.js";
import { cmdConfigGet, cmdConfigList, cmdConfigSet } from "./commands/config.js";
import { cmdLogClean, cmdLogList, cmdLogShow } from "./commands/log.js";
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
import { cmdSkillCli } from "./commands/skill.js";
import {
cmdSkillActor,
cmdSkillAdapter,
cmdSkillArchitecture,
cmdSkillAuthor,
cmdSkillCli,
cmdSkillDeveloper,
cmdSkillList,
cmdSkillModerator,
cmdSkillUser,
cmdSkillYaml,
} from "./commands/skill.js";
import { cmdStepFork, cmdStepList, cmdStepRead, cmdStepShow } from "./commands/step.js";
import {
cmdThreadCancel,
@@ -26,7 +38,6 @@ import {
cmdThreadStart,
cmdThreadStop,
THREAD_READ_DEFAULT_QUOTA,
type ThreadStatus,
} from "./commands/thread.js";
import { parseTimeInput } from "./commands/thread-time-parser.js";
import { cmdWorkflowAdd, cmdWorkflowList, cmdWorkflowShow } from "./commands/workflow.js";
@@ -55,8 +66,7 @@ program
.description(
"Stateless workflow CLI\n\n" +
"Four-layer architecture:\n" +
" workflow → thread → step → turn\n" +
" 模板定义 执行实例 单步结果 agent内部交互",
" workflow → thread → step → turn",
)
.version(pkg.default.version, "-V, --version");
program.option("--format <fmt>", "Output format: json or yaml", "json");
@@ -107,10 +117,17 @@ thread
.description("Create a thread without executing")
.argument("<workflow>", "Workflow name or hash")
.requiredOption("-p, --prompt <text>", "User prompt")
.action((workflow: string, opts: { prompt: string }) => {
.option("--cwd <path>", "Working directory for thread execution (default: process.cwd())")
.action((workflow: string, opts: { prompt: string; cwd: string | undefined }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadStart(storageRoot, workflow, opts.prompt, process.cwd());
const result = await cmdThreadStart(
storageRoot,
workflow,
opts.prompt,
process.cwd(),
opts.cwd ?? process.cwd(),
);
writeOutput(result);
});
});
@@ -176,11 +193,11 @@ function parseStatusFilter(status: string | undefined): ThreadStatus[] | null {
if (raw === "active") return ["idle", "running"];
const parts = raw.split(",").map((s) => s.trim());
const validStatuses: ThreadStatus[] = ["idle", "running", "completed"];
const validStatuses: ThreadStatus[] = ["idle", "running", "completed", "cancelled"];
for (const part of parts) {
if (!validStatuses.includes(part as ThreadStatus)) {
process.stderr.write(
`Invalid status: ${part}. Must be one of: idle, running, completed, active\n`,
`Invalid status: ${part}. Must be one of: idle, running, completed, cancelled, active\n`,
);
process.exit(1);
}
@@ -233,7 +250,7 @@ thread
.description("List threads")
.option(
"--status <status>",
"Filter by status: idle, running, completed, active (idle+running), or comma-separated values",
"Filter by status: idle, running, completed, cancelled, 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')")
@@ -474,6 +491,7 @@ For more information, see: uwf help thread list
});
const skill = program.command("skill").description("Built-in skill references for agents");
skill.addHelpCommand(false);
skill
.command("cli")
@@ -482,6 +500,69 @@ skill
console.log(cmdSkillCli());
});
skill
.command("architecture")
.description("Print the architecture reference")
.action(() => {
console.log(cmdSkillArchitecture());
});
skill
.command("yaml")
.description("Print the workflow YAML schema reference")
.action(() => {
console.log(cmdSkillYaml());
});
skill
.command("actor")
.description("Print the actor reference (frontmatter protocol + CAS)")
.action(() => {
console.log(cmdSkillActor());
});
skill
.command("adapter")
.description("Print the adapter reference (building agent adapters)")
.action(() => {
console.log(cmdSkillAdapter());
});
skill
.command("author")
.description("Print the author reference (workflow YAML design guide)")
.action(() => {
console.log(cmdSkillAuthor());
});
skill
.command("developer")
.description("Print the developer reference (coding conventions + architecture)")
.action(() => {
console.log(cmdSkillDeveloper());
});
skill
.command("moderator")
.description("Print the moderator reference")
.action(() => {
console.log(cmdSkillModerator());
});
skill
.command("user")
.description("Print the user reference (CLI guide + typical workflows)")
.action(() => {
console.log(cmdSkillUser());
});
skill
.command("list")
.description("List all available skill names")
.action(() => {
console.log(cmdSkillList().join("\n"));
});
program
.command("setup")
.description("Configure provider, model, and agent")
@@ -489,7 +570,7 @@ program
.option("--base-url <url>", "OpenAI-compatible API base URL")
.option("--api-key <key>", "API key")
.option("--model <name>", "Default model name")
.option("--agent <name>", "Default agent alias")
.option("--agent <name>", "Default agent adapter (e.g. hermes → uwf-hermes)")
.action(
(opts: {
provider?: string;
@@ -677,6 +758,47 @@ log
});
});
const config = program.command("config").description("Configuration management");
config
.command("list")
.description("Display all configuration values (masks API keys)")
.action(() => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdConfigList(storageRoot);
writeOutput(result);
});
});
config
.command("get")
.description("Get a specific configuration value")
.argument(
"<key>",
"Dot-notation path to config value (e.g., defaultAgent, providers.dashscope.baseUrl)",
)
.action((key: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdConfigGet(storageRoot, key);
writeOutput({ value: result });
});
});
config
.command("set")
.description("Set a specific configuration value")
.argument("<key>", "Dot-notation path to config value")
.argument("<value>", "New value (use JSON array for 'args' key, e.g., '[\"--flag\"]')")
.action((key: string, value: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdConfigSet(storageRoot, key, value);
writeOutput(result);
});
});
program.parseAsync(process.argv).catch((e: unknown) => {
const message = e instanceof Error ? e.message : String(e);
process.stderr.write(`${message}\n`);
@@ -0,0 +1,304 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { parse, stringify } from "yaml";
/**
* Valid configuration key schema
*/
const VALID_CONFIG_KEYS: Record<
string,
{ nested: boolean; knownFields?: string[]; minDepth?: number }
> = {
providers: {
nested: true,
knownFields: ["baseUrl", "apiKey"],
},
models: {
nested: true,
knownFields: ["provider", "name"],
},
agents: {
nested: true,
knownFields: ["command", "args"],
},
agentOverrides: {
nested: true,
// agentOverrides.<workflowName>.<roleName> = agentAlias (string value)
// No knownFields — workflow/role names are user-defined
},
modelOverrides: {
nested: true,
minDepth: 2,
// modelOverrides.<scenario> = modelAlias (string value)
// No knownFields — scenarios are user-defined
},
defaultAgent: { nested: false },
defaultModel: { nested: false },
};
/**
* Validate a config key path against the known schema
*/
function validateConfigKey(path: string[]): void {
if (path.length === 0) {
throw new Error("Path cannot be empty");
}
const topLevel = path[0];
const schema = VALID_CONFIG_KEYS[topLevel];
if (!schema) {
const validKeys = Object.keys(VALID_CONFIG_KEYS).join(", ");
throw new Error(`Unknown config key: ${topLevel}. Valid top-level keys are: ${validKeys}`);
}
// Scalar keys cannot have nested paths
if (!schema.nested && path.length > 1) {
throw new Error(`${topLevel} is a scalar key and cannot have nested properties`);
}
// Nested keys must have at least minDepth segments (default 3)
const minDepth = schema.minDepth ?? 3;
if (schema.nested && path.length < minDepth) {
const fields = schema.knownFields?.join(", ") ?? "";
throw new Error(
`Incomplete path for ${topLevel}. Must specify a field (e.g., ${topLevel}.<name>.<field>). Valid fields: ${fields}`,
);
}
// Validate the field name for nested keys
if (schema.nested && path.length >= 3 && schema.knownFields) {
const field = path[path.length - 1];
if (!schema.knownFields.includes(field)) {
throw new Error(
`Unknown field '${field}' in ${topLevel}. Valid fields are: ${schema.knownFields.join(", ")}`,
);
}
}
}
/**
* Returns the path to the config.yaml file
*/
export function getConfigPath(storageRoot: string): string {
return join(storageRoot, "config.yaml");
}
/**
* Load and parse YAML config file
*/
export function loadConfig(configPath: string): Record<string, unknown> {
if (!existsSync(configPath)) {
throw new Error(`Config file not found: ${configPath}`);
}
const content = readFileSync(configPath, "utf8");
if (!content.trim()) {
return {};
}
try {
const parsed = parse(content);
return (parsed ?? {}) as Record<string, unknown>;
} catch (error) {
throw new Error(
`Invalid YAML in config file: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Save config as YAML
*/
export function saveConfig(configPath: string, config: Record<string, unknown>): void {
const dir = join(configPath, "..");
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const yaml = stringify(config);
writeFileSync(configPath, yaml, "utf8");
}
/**
* Parse dot-notation key into path segments
*/
export function parseDotPath(key: string): string[] {
return key.split(".");
}
/**
* Get nested value from object using path array
*/
export function getNestedValue(obj: Record<string, unknown>, path: string[]): unknown {
let current: unknown = obj;
for (const segment of path) {
if (current === null || current === undefined || typeof current !== "object") {
return undefined;
}
current = (current as Record<string, unknown>)[segment];
}
return current;
}
/**
* Set nested value in object using path array (mutates obj)
*/
export function setNestedValue(obj: Record<string, unknown>, path: string[], value: unknown): void {
if (path.length === 0) {
throw new Error("Path cannot be empty");
}
let current: Record<string, unknown> = obj;
// Navigate/create to the parent of the target
for (let i = 0; i < path.length - 1; i++) {
const segment = path[i];
const next = current[segment];
if (next === null || next === undefined) {
// Create intermediate object
const newObj: Record<string, unknown> = {};
current[segment] = newObj;
current = newObj;
} else if (typeof next === "object" && !Array.isArray(next)) {
// Navigate into existing object
current = next as Record<string, unknown>;
} else {
// Cannot navigate into non-object
throw new Error(
`Cannot set property '${path[i + 1]}' on non-object at path '${path.slice(0, i + 1).join(".")}'`,
);
}
}
// Set the final value
const lastSegment = path[path.length - 1];
current[lastSegment] = value;
}
/**
* Deep clone and mask all apiKey values in providers section
*/
export function maskApiKeys(config: Record<string, unknown>): Record<string, unknown> {
// Deep clone
const cloned = JSON.parse(JSON.stringify(config)) as Record<string, unknown>;
// Mask apiKey values in providers
if (cloned.providers && typeof cloned.providers === "object") {
const providers = cloned.providers as Record<string, unknown>;
for (const providerName of Object.keys(providers)) {
const provider = providers[providerName];
if (provider && typeof provider === "object") {
const providerObj = provider as Record<string, unknown>;
if ("apiKey" in providerObj) {
providerObj.apiKey = "***MASKED***";
}
}
}
}
return cloned;
}
/**
* List all configuration values (masks API keys)
*/
export async function cmdConfigList(storageRoot: string): Promise<unknown> {
const configPath = getConfigPath(storageRoot);
const config = loadConfig(configPath);
const masked = maskApiKeys(config);
return masked;
}
/**
* Get a specific configuration value
*/
export async function cmdConfigGet(storageRoot: string, key: string): Promise<unknown> {
const configPath = getConfigPath(storageRoot);
const config = loadConfig(configPath);
const path = parseDotPath(key);
const value = getNestedValue(config, path);
if (value === undefined) {
throw new Error(`Key not found: ${key}`);
}
return value;
}
/**
* Parse value for args key (must be JSON array)
*/
function parseArgsValue(value: string): unknown {
if (value.startsWith("[")) {
try {
const parsed = JSON.parse(value);
if (!Array.isArray(parsed)) {
throw new Error("Value must be an array");
}
return parsed;
} catch (error) {
throw new Error(
`Invalid JSON array for args key: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
throw new Error("Value for 'args' key must be a JSON array starting with '['");
}
/**
* Validate that we're not setting a property on a non-object
*/
function validateParentPath(
config: Record<string, unknown>,
path: string[],
lastSegment: string,
): void {
if (path.length > 1) {
const parentPath = path.slice(0, -1);
const parent = getNestedValue(config, parentPath);
if (parent !== null && parent !== undefined && typeof parent !== "object") {
throw new Error(
`Cannot set property '${lastSegment}' on non-object at path '${parentPath.join(".")}'`,
);
}
}
}
/**
* Set a specific configuration value
*/
export async function cmdConfigSet(
storageRoot: string,
key: string,
value: string,
): Promise<unknown> {
const configPath = getConfigPath(storageRoot);
// Load existing config or create empty one
let config: Record<string, unknown>;
if (existsSync(configPath)) {
config = loadConfig(configPath);
} else {
config = {};
}
const path = parseDotPath(key);
// Validate the key path
validateConfigKey(path);
const lastSegment = path[path.length - 1];
// Parse value if it's for an array key (args)
let parsedValue: unknown = value;
if (lastSegment === "args") {
parsedValue = parseArgsValue(value);
}
// Validate we're not setting a property on a non-object
validateParentPath(config, path, lastSegment);
setNestedValue(config, path, parsedValue);
saveConfig(configPath, config);
return { key, value: parsedValue };
}
+86 -49
View File
@@ -85,10 +85,6 @@ function getConfigPath(root: string): string {
return join(root, "config.yaml");
}
function getEnvPath(root: string): string {
return join(root, ".env");
}
/**
* Load existing config.yaml or return empty structure.
*/
@@ -106,37 +102,6 @@ function loadExistingConfig(configPath: string): Record<string, unknown> {
return {};
}
/**
* Load existing .env as key=value map.
*/
function loadEnvFile(envPath: string): Record<string, string> {
const env: Record<string, string> = {};
try {
if (existsSync(envPath)) {
for (const line of readFileSync(envPath, "utf8").split("\n")) {
const trimmed = line.trim();
if (trimmed === "" || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq > 0) {
env[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
}
}
}
} catch {
// ignore
}
return env;
}
function saveEnvFile(envPath: string, env: Record<string, string>): void {
const lines = Object.entries(env).map(([k, v]) => `${k}=${v}`);
writeFileSync(envPath, `${lines.join("\n")}\n`, "utf8");
}
function apiKeyEnvName(providerName: string): string {
return `${providerName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
}
// ──────────────────────────────────────────────────────────────────────────────
// Extracted helpers — _discoverAgents
// ──────────────────────────────────────────────────────────────────────────────
@@ -297,6 +262,80 @@ export function _printModelMenu(models: string[], termCols: number): void {
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Agent selection prompt
// ──────────────────────────────────────────────────────────────────────────────
/** Known agent binary → display label mapping. */
const KNOWN_AGENTS: Record<string, string> = {
"uwf-hermes": "Hermes (hermes-agent)",
"uwf-claude-code": "Claude Code",
"uwf-cursor": "Cursor",
"uwf-builtin": "Built-in (lightweight, no external agent)",
};
/** Extract short agent name from binary name: uwf-claude-code → claude-code */
export function _agentNameFromBinary(binary: string): string {
return binary.replace(/^uwf-/, "");
}
/** Prints numbered agent list to stdout. */
export function _printAgentMenu(agents: string[]): void {
const numWidth = String(agents.length).length;
for (let i = 0; i < agents.length; i++) {
const bin = agents[i] ?? "";
const label = KNOWN_AGENTS[bin] ?? bin;
const num = String(i + 1).padStart(numWidth);
console.log(` ${num}) ${label} (${bin})`);
}
console.log("");
}
/**
* Interactive agent selection. Discovers uwf-* binaries, lets user pick default.
* Returns short agent name (e.g. "hermes", "claude-code").
*/
export async function _promptAgentSelection(
rl: ReturnType<typeof createInterface>,
): Promise<string> {
console.log("Discovering installed agents...\n");
const agents = await _discoverAgents();
if (agents.length === 0) {
console.log(" No uwf-* agent binaries found in PATH.\n");
console.log(" Install one first, for example:");
console.log(" npm i -g @uncaged/workflow-agent-hermes");
console.log(" npm i -g @uncaged/workflow-agent-claude-code\n");
const manual = (
await rl.question("Agent binary name (e.g. uwf-hermes), or press Enter to skip: ")
).trim();
if (!manual) return "hermes";
return _agentNameFromBinary(manual.startsWith("uwf-") ? manual : `uwf-${manual}`);
}
if (agents.length === 1) {
const name = _agentNameFromBinary(agents[0] ?? "uwf-hermes");
const label = KNOWN_AGENTS[agents[0] ?? ""] ?? agents[0];
console.log(` Found 1 agent: ${label} — auto-selected.\n`);
return name;
}
console.log(` Found ${agents.length} agents:\n`);
_printAgentMenu(agents);
const choice = (await rl.question(`Choose default agent [1-${agents.length}]: `)).trim();
const n = Number.parseInt(choice, 10);
if (!Number.isNaN(n) && n >= 1 && n <= agents.length) {
const selected = agents[n - 1] ?? "uwf-hermes";
const name = _agentNameFromBinary(selected);
console.log(`${name}\n`);
return name;
}
// Treat as literal name
const name = _agentNameFromBinary(choice.startsWith("uwf-") ? choice : `uwf-${choice}`);
console.log(`${name}\n`);
return name;
}
type ValidationResult = { ok: boolean; error: string | null };
/** Prints the model validation result to stdout. */
@@ -323,8 +362,7 @@ function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record
: {}
) as Record<string, unknown>;
const envName = apiKeyEnvName(args.provider);
providers[args.provider] = { baseUrl: args.baseUrl, apiKeyEnv: envName };
providers[args.provider] = { baseUrl: args.baseUrl, apiKey: args.apiKey };
const models = (
typeof existing.models === "object" && existing.models !== null
@@ -339,9 +377,10 @@ function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record
: {}
) as Record<string, unknown>;
const agentName = args.agent ?? "hermes";
if (Object.keys(agents).length === 0) {
agents.hermes = { command: "uwf-hermes", args: [] };
const agentName = _agentNameFromBinary(args.agent ?? "hermes");
// Ensure the selected agent has an entry
if (!agents[agentName]) {
agents[agentName] = { command: `uwf-${agentName}`, args: [] };
}
return {
@@ -349,7 +388,7 @@ function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record
providers,
models,
agents,
defaultAgent: existing.defaultAgent ?? agentName,
defaultAgent: agentName,
defaultModel: existing.defaultModel ?? "default",
};
}
@@ -362,25 +401,17 @@ export async function cmdSetup(args: SetupArgs): Promise<Record<string, unknown>
mkdirSync(storageRoot, { recursive: true });
const configPath = getConfigPath(storageRoot);
const envPath = getEnvPath(storageRoot);
const existing = loadExistingConfig(configPath);
const merged = mergeConfig(existing, args);
writeFileSync(configPath, stringify(merged, { indent: 2 }), "utf8");
// Write API key to .env
const envName = apiKeyEnvName(args.provider);
const envData = loadEnvFile(envPath);
envData[envName] = args.apiKey;
saveEnvFile(envPath, envData);
// Validate model connectivity
const validation = await validateModel(args.baseUrl, args.apiKey, args.model);
return {
configPath,
envPath,
provider: args.provider,
model: args.model,
defaultAgent: merged.defaultAgent,
@@ -543,11 +574,17 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
rl2.close();
console.log(`${providerName}/${model}\n`);
// 4. Agent discovery & selection
const rl3 = createInterface({ input, output });
const agentName = await _promptAgentSelection(rl3);
rl3.close();
const setupResult = await cmdSetup({
provider: providerName,
baseUrl,
apiKey,
model,
agent: agentName,
storageRoot,
});
+27 -1
View File
@@ -1 +1,27 @@
export { generateCliReference as cmdSkillCli } from "@uncaged/workflow-util";
export {
generateActorReference as cmdSkillActor,
generateAdapterReference as cmdSkillAdapter,
generateArchitectureReference as cmdSkillArchitecture,
generateAuthorReference as cmdSkillAuthor,
generateCliReference as cmdSkillCli,
generateDeveloperReference as cmdSkillDeveloper,
generateModeratorReference as cmdSkillModerator,
generateUserReference as cmdSkillUser,
generateYamlReference as cmdSkillYaml,
} from "@uncaged/workflow-util";
const SKILL_NAMES = [
"cli",
"architecture",
"yaml",
"moderator",
"actor",
"user",
"author",
"developer",
"adapter",
] as const;
export function cmdSkillList(): ReadonlyArray<string> {
return [...SKILL_NAMES];
}
+79 -16
View File
@@ -19,9 +19,16 @@ import {
walkChain,
} from "./shared.js";
type TurnToolCall = {
name: string;
args: string;
};
type TurnData = {
index: number;
role: string;
content: string;
toolCalls: TurnToolCall[] | null;
};
/**
@@ -128,8 +135,74 @@ function loadStepDetail(store: BootstrapCapableStore, detailRef: CasRef): Record
return detailNode.payload as Record<string, unknown>;
}
function parseTurnToolCalls(raw: unknown): TurnToolCall[] | null {
if (!Array.isArray(raw) || raw.length === 0) {
return null;
}
const calls: TurnToolCall[] = [];
for (const entry of raw) {
if (typeof entry !== "object" || entry === null) {
continue;
}
const record = entry as Record<string, unknown>;
const name = record.name;
const args = record.args;
if (typeof name === "string") {
calls.push({ name, args: typeof args === "string" ? args : "" });
}
}
return calls.length > 0 ? calls : null;
}
function formatTurnBody(turn: TurnData): string {
const parts: string[] = [];
parts.push(`**Turn role:** ${turn.role}`);
if (turn.toolCalls !== null) {
for (const call of turn.toolCalls) {
const argsSuffix = call.args !== "" ? `\`${call.args}\`` : "";
parts.push(`- **${call.name}**${argsSuffix}`);
}
}
if (turn.content !== "") {
if (parts.length > 0) {
parts.push("");
}
parts.push(turn.content);
}
return parts.join("\n");
}
function parseSingleTurn(
store: BootstrapCapableStore,
turnRef: unknown,
fallbackIndex: number,
): TurnData | null {
if (typeof turnRef !== "string") {
return null;
}
const turnNode = store.get(turnRef as CasRef);
if (turnNode === null) {
return null;
}
const turn = turnNode.payload as Record<string, unknown>;
const content = typeof turn.content === "string" ? turn.content : "";
const toolCalls = parseTurnToolCalls(turn.toolCalls);
if (content === "" && toolCalls === null) {
return null;
}
return {
index: typeof turn.index === "number" ? turn.index : fallbackIndex,
role: typeof turn.role === "string" ? turn.role : "assistant",
content,
toolCalls,
};
}
/**
* Load all turn nodes from CAS store and extract content
* Load all turn nodes from CAS store and extract display fields
*/
function loadTurnData(store: BootstrapCapableStore, turns: unknown): TurnData[] {
if (!Array.isArray(turns) || turns.length === 0) {
@@ -138,19 +211,9 @@ function loadTurnData(store: BootstrapCapableStore, turns: unknown): TurnData[]
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,
});
const parsed = parseSingleTurn(store, turnRef, turnData.length);
if (parsed !== null) {
turnData.push(parsed);
}
}
return turnData;
@@ -168,7 +231,7 @@ function selectTurnsForQuota(turnData: TurnData[], availableQuota: number): Turn
if (turn === undefined) continue;
const turnHeader = `## Turn ${turn.index + 1}\n\n`;
const turnBlock = turnHeader + turn.content;
const turnBlock = turnHeader + formatTurnBody(turn);
const separatorCost = selectedTurns.length > 0 ? 2 : 0;
const addCost = turnBlock.length + separatorCost;
@@ -213,7 +276,7 @@ function formatStepMarkdown(
parts.push("");
parts.push(`## Turn ${turn.index + 1}`);
parts.push("");
parts.push(turn.content);
parts.push(formatTurnBody(turn));
}
return parts.join("\n");
+58 -11
View File
@@ -2,8 +2,6 @@ import { execFileSync, spawn } from "node:child_process";
import { access, readFile } from "node:fs/promises";
import { dirname, isAbsolute, resolve as resolvePath } from "node:path";
import { validate } from "@uncaged/json-cas";
import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-util-agent";
import { evaluate } from "../moderator/index.js";
import type {
AgentAlias,
AgentConfig,
@@ -14,6 +12,7 @@ import type {
StepOutput,
ThreadId,
ThreadListItem,
ThreadStatus,
ThreadsIndex,
WorkflowConfig,
WorkflowPayload,
@@ -24,9 +23,12 @@ import {
generateUlid,
type ProcessLogger,
} from "@uncaged/workflow-util";
import type { AdapterOutput } from "@uncaged/workflow-util-agent";
import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-util-agent";
import { config as loadDotenv } from "dotenv";
import { parse } from "yaml";
import { createMarker, deleteMarker, isThreadRunning } from "../background/index.js";
import { evaluate } from "../moderator/index.js";
import {
appendThreadHistory,
createUwfStore,
@@ -266,7 +268,13 @@ export async function cmdThreadStart(
workflowId: string,
prompt: string,
projectRoot: string,
cwd: string = process.cwd(),
): Promise<StartOutput> {
// Validate cwd is an absolute path
if (!isAbsolute(cwd)) {
fail("cwd must be an absolute path");
}
const uwf = await createUwfStore(storageRoot);
const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId, projectRoot);
@@ -278,6 +286,7 @@ export async function cmdThreadStart(
const startPayload: StartNodePayload = {
workflow: workflowHash,
prompt,
cwd,
};
const headHash = await uwf.store.put(uwf.schemas.startNode, startPayload);
@@ -308,10 +317,16 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
if (workflow === null) {
fail(`failed to resolve workflow from head: ${activeHead}`);
}
// Check if thread is running
const runningMarker = await isThreadRunning(storageRoot, threadId);
const status: ThreadStatus = runningMarker !== null ? "running" : "idle";
return {
workflow,
thread: threadId,
head: activeHead,
status,
done: false,
background: null,
};
@@ -319,10 +334,13 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
const hist = await findThreadInHistory(storageRoot, threadId);
if (hist !== null) {
const status: ThreadStatus = hist.reason === "cancelled" ? "cancelled" : "completed";
return {
workflow: hist.workflow,
thread: threadId,
head: hist.head,
status,
done: true,
background: null,
};
@@ -331,8 +349,6 @@ export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Pr
fail(`thread not found: ${threadId}`);
}
export type ThreadStatus = "idle" | "running" | "completed";
export type ThreadListItemWithStatus = ThreadListItem & {
status: ThreadStatus;
};
@@ -389,7 +405,7 @@ async function collectCompletedThreads(
thread: entry.thread,
workflow: entry.workflow,
head: entry.head,
status: "completed",
status: entry.reason === "cancelled" ? "cancelled" : "completed",
});
}
}
@@ -444,7 +460,10 @@ export async function cmdThreadList(
let items = await collectActiveThreads(storageRoot, uwf, index);
// Collect completed threads (if relevant for status filter)
const includeCompleted = statusFilter === null || statusFilter.includes("completed");
const includeCompleted =
statusFilter === null ||
statusFilter.includes("completed") ||
statusFilter.includes("cancelled");
if (includeCompleted) {
const activeIds = new Set(items.map((i) => i.thread));
const completedItems = await collectCompletedThreads(storageRoot, activeIds);
@@ -769,7 +788,8 @@ function spawnAgent(
threadId: ThreadId,
role: string,
edgePrompt: string,
): CasRef {
cwd: string,
): AdapterOutput {
const argv = [...agent.args, "--thread", threadId, "--role", role, "--prompt", edgePrompt];
let stdout: string;
try {
@@ -777,6 +797,7 @@ function spawnAgent(
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 50 * 1024 * 1024, // 50 MB — stream-json output can be large
cwd,
});
} catch (e) {
const err = e as NodeJS.ErrnoException & { stderr?: Buffer | string | null };
@@ -791,10 +812,22 @@ function spawnAgent(
}
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
if (!isCasRef(line)) {
failStep(plog, `agent stdout is not a valid CAS hash: ${line || "(empty)"}`);
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
failStep(plog, `agent stdout last line is not valid JSON: ${line || "(empty)"}`);
}
return line;
const obj = parsed as Record<string, unknown>;
if (
typeof obj !== "object" ||
obj === null ||
typeof obj.stepHash !== "string" ||
!isCasRef(obj.stepHash as string)
) {
failStep(plog, `agent stdout JSON missing valid stepHash: ${line}`);
}
return obj as unknown as AdapterOutput;
}
async function archiveThread(
@@ -811,6 +844,7 @@ async function archiveThread(
workflow,
head,
completedAt: Date.now(),
reason: "completed",
});
}
@@ -934,6 +968,7 @@ async function cmdThreadStepBackground(
workflow: workflowHash,
thread: threadId,
head: headHash,
status: "running",
done: false,
background: true,
},
@@ -976,6 +1011,7 @@ async function cmdThreadStepOnce(
workflow: workflowHash,
thread: threadId,
head: headHash,
status: "completed",
done: true,
background: null,
};
@@ -983,6 +1019,11 @@ async function cmdThreadStepOnce(
const role = nextResult.value.role;
const edgePrompt = nextResult.value.prompt;
// Resolve cwd: use edge location if provided, otherwise inherit thread.cwd
const threadCwd = chain.start.cwd;
const effectiveCwd = nextResult.value.location !== null ? nextResult.value.location : threadCwd;
const config = await loadWorkflowConfig(storageRoot);
const agent = resolveAgentConfig(config, workflow, role, agentOverride);
@@ -991,7 +1032,8 @@ async function cmdThreadStepOnce(
});
loadDotenv({ path: getEnvPath(storageRoot) });
const newHead = spawnAgent(plog, agent, threadId, role, edgePrompt);
const agentResult = spawnAgent(plog, agent, threadId, role, edgePrompt, effectiveCwd);
const newHead = agentResult.stepHash as CasRef;
plog.log(PL_AGENT_DONE, `agent returned head=${newHead}`, null);
@@ -1023,10 +1065,14 @@ async function cmdThreadStepOnce(
await archiveThread(storageRoot, threadId, workflowHash, newHead);
}
// Determine status based on whether thread is done and running state
const status: ThreadStatus = done ? "completed" : "idle";
return {
workflow: workflowHash,
thread: threadId,
head: newHead,
status,
done,
background: null,
};
@@ -1147,6 +1193,7 @@ export async function cmdThreadCancel(
workflow,
head,
completedAt: Date.now(),
reason: "cancelled",
};
await appendThreadHistory(storageRoot, historyEntry);
@@ -61,6 +61,7 @@ function normalizeGraph(
normalized[status] = {
role: target.role,
prompt: target.prompt,
location: target.location ?? null,
};
}
result[node] = normalized;
@@ -0,0 +1,198 @@
import { describe, expect, test } from "vitest";
import { evaluate } from "../evaluate.js";
describe("Edge prompt template variable resolution", () => {
test("returns error when rendered prompt is empty string", () => {
const graph = {
$START: {
_: { role: "classifier", prompt: "{{{userPrompt}}}", location: null },
},
};
const result = evaluate(graph, "$START", {});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain("prompt");
expect(result.error.message).toContain("empty");
}
});
test("returns error when rendered prompt is whitespace-only", () => {
const graph = {
$START: {
_: { role: "classifier", prompt: " {{{userPrompt}}} ", location: null },
},
};
const result = evaluate(graph, "$START", {});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain("prompt");
expect(result.error.message).toContain("empty");
}
});
test("succeeds when all template variables resolve to non-empty values", () => {
const graph = {
$START: {
_: { role: "classifier", prompt: "{{{userPrompt}}}", location: null },
},
};
const result = evaluate(graph, "$START", { userPrompt: "Fix the bug" });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.prompt).toBe("Fix the bug");
}
});
test("succeeds with static (no-variable) prompt", () => {
const graph = {
$START: {
_: { role: "classifier", prompt: "Classify this input", location: null },
},
};
const result = evaluate(graph, "$START", {});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.prompt).toBe("Classify this input");
}
});
test("succeeds when prompt has mix of static text and unresolved variables", () => {
const graph = {
$START: {
_: { role: "classifier", prompt: "Please handle: {{{userPrompt}}}", location: null },
},
};
const result = evaluate(graph, "$START", {});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.prompt).toBe("Please handle: ");
}
});
test("returns error when ALL variables missing and no static text remains", () => {
const graph = {
$START: {
_: { role: "classifier", prompt: "{{{a}}}{{{b}}}", location: null },
},
};
const result = evaluate(graph, "$START", {});
expect(result.ok).toBe(false);
});
});
describe("Moderator location resolution", () => {
test("returns null location when edge has no location field", () => {
const graph = {
planner: {
ready: {
role: "coder",
prompt: "Implement the code",
location: null,
},
},
};
const result = evaluate(graph, "planner", { $status: "ready" });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.location).toBe(null);
}
});
test("resolves static location string", () => {
const graph = {
planner: {
ready: {
role: "coder",
prompt: "Implement the code",
location: "/static/path",
},
},
};
const result = evaluate(graph, "planner", { $status: "ready" });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.location).toBe("/static/path");
}
});
test("resolves mustache template location", () => {
const graph = {
planner: {
ready: {
role: "coder",
prompt: "Implement the code",
location: "{{{repoPath}}}",
},
},
};
const result = evaluate(graph, "planner", {
$status: "ready",
repoPath: "/home/user/repo",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.location).toBe("/home/user/repo");
}
});
test("resolves mustache template with multiple variables", () => {
const graph = {
planner: {
ready: {
role: "coder",
prompt: "Implement the code",
location: "{{{basePath}}}/{{{projectName}}}",
},
},
};
const result = evaluate(graph, "planner", {
$status: "ready",
basePath: "/home/user",
projectName: "myproject",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.location).toBe("/home/user/myproject");
}
});
test("handles missing template variable gracefully", () => {
const graph = {
planner: {
ready: {
role: "coder",
prompt: "Implement the code",
location: "{{{repoPath}}}",
},
},
};
const result = evaluate(graph, "planner", { $status: "ready" });
expect(result.ok).toBe(true);
if (result.ok) {
// Mustache renders missing variables as empty string
expect(result.value.location).toBe("");
}
});
});
@@ -43,7 +43,16 @@ export function evaluate(
try {
const prompt = mustache.render(target.prompt, lastOutput);
return { ok: true, value: { role: target.role, prompt } };
if (prompt.trim() === "") {
return {
ok: false,
error: new Error(
`edge prompt resolved to empty string for role "${target.role}" (template: "${target.prompt}"). Check that upstream output includes required variables.`,
),
};
}
const location = target.location !== null ? mustache.render(target.location, lastOutput) : null;
return { ok: true, value: { role: target.role, prompt, location } };
} catch (error) {
return {
ok: false,
@@ -4,4 +4,6 @@ export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
export type EvaluateResult = {
role: string;
prompt: string;
/** Resolved working directory from edge location field (null = inherit thread cwd). */
location: string | null;
};
+10 -1
View File
@@ -88,6 +88,7 @@ export function getHistoryPath(storageRoot: string): string {
export type ThreadHistoryLine = ThreadListItem & {
completedAt: number;
reason: "completed" | "cancelled" | null;
};
export type UwfStore = {
@@ -228,7 +229,15 @@ export async function loadThreadHistory(storageRoot: string): Promise<ThreadHist
typeof head === "string" &&
typeof completedAt === "number"
) {
lines.push({ thread: thread as ThreadId, workflow, head, completedAt });
const reason = rec.reason;
const parsedReason = reason === "completed" || reason === "cancelled" ? reason : null;
lines.push({
thread: thread as ThreadId,
workflow,
head,
completedAt,
reason: parsedReason,
});
}
}
return lines;
@@ -23,6 +23,28 @@ function isOneOfSchema(fm: unknown): fm is SchemaObj & { oneOf: SchemaObj[] } {
return Array.isArray(obj.oneOf);
}
/** Check if a frontmatter schema uses enum-based multi-exit ($status with multiple enum values). */
function isEnumMultiExit(fm: unknown): boolean {
if (typeof fm !== "object" || fm === null) return false;
const obj = fm as SchemaObj;
const props = obj.properties as Record<string, SchemaObj> | undefined;
if (!props?.$status) return false;
const statusDef = props.$status;
if (!Array.isArray(statusDef.enum)) return false;
// Filter out "_" (wildcard) — if remaining values > 1, it's multi-exit
const statuses = (statusDef.enum as string[]).filter((s) => s !== "_");
return statuses.length > 1;
}
/** Extract status values from an enum-based $status field. */
function getEnumStatuses(fm: SchemaObj): string[] {
const props = fm.properties as Record<string, SchemaObj> | undefined;
if (!props?.$status) return [];
const statusDef = props.$status;
if (!Array.isArray(statusDef.enum)) return [];
return (statusDef.enum as string[]).filter((s) => s !== "_");
}
/** Get property names from a schema object. */
function getPropertyNames(schema: SchemaObj): Set<string> {
const props = schema.properties;
@@ -230,6 +252,11 @@ function checkRoleConsistency(payload: WorkflowPayload, errors: string[]): void
checkOneOfDiscriminant(roleName, variants, statuses, errors);
checkMultiExitEdges(roleName, graphKeys, new Set(statuses), errors);
checkMultiExitMustache(roleName, graphEntry, variants, errors);
} else if (isEnumMultiExit(fm)) {
const statuses = getEnumStatuses(fm as SchemaObj);
checkMultiExitEdges(roleName, graphKeys, new Set(statuses), errors);
// For enum-based schemas, mustache vars come from the flat properties
checkSingleExitMustache(roleName, graphEntry, fm as SchemaObj, errors);
} else {
checkSingleExitRole(roleName, graphKeys, graphEntry, fm as SchemaObj | null, errors);
}
@@ -265,6 +292,27 @@ function checkSingleExitRole(
}
}
/** Check mustache vars in all edge prompts against flat schema properties. */
function checkSingleExitMustache(
roleName: string,
graphEntry: Record<string, { role: string; prompt: string }>,
fm: SchemaObj,
errors: string[],
): void {
const propNames = getPropertyNames(fm);
for (const [status, target] of Object.entries(graphEntry)) {
const vars = extractMustacheVars(target.prompt);
for (const v of vars) {
if (v === "$status") continue;
if (!propNames.has(v)) {
errors.push(
`prompt variable "${v}" in graph[${roleName}][${status}] not found in role "${roleName}" frontmatter`,
);
}
}
}
}
/**
* Validate a parsed WorkflowPayload for semantic correctness.
* Returns an array of error messages. Empty array = valid.
+24 -2
View File
@@ -36,8 +36,13 @@ function isTarget(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
const hasValidLocation =
value.location === undefined || value.location === null || typeof value.location === "string";
return (
typeof value.role === "string" && typeof value.prompt === "string" && value.prompt.trim() !== ""
typeof value.role === "string" &&
typeof value.prompt === "string" &&
value.prompt.trim() !== "" &&
hasValidLocation
);
}
@@ -95,5 +100,22 @@ export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
if (!isStringRecord(raw.roles, isRoleDefinition) || !isGraph(raw.graph)) {
return null;
}
return raw as WorkflowPayload;
// Normalize location field: undefined → null
const normalized = { ...raw } as WorkflowPayload;
for (const roleName of Object.keys(normalized.graph)) {
const statusMap = normalized.graph[roleName];
if (statusMap !== undefined) {
for (const status of Object.keys(statusMap)) {
const target = statusMap[status];
if (target !== undefined) {
if (target.location === undefined) {
target.location = null;
}
}
}
}
}
return normalized;
}
+1 -4
View File
@@ -5,8 +5,5 @@
"outDir": "dist"
},
"include": ["src"],
"references": [
{ "path": "../workflow-protocol" },
{ "path": "../workflow-util-agent" }
]
"references": [{ "path": "../workflow-protocol" }, { "path": "../workflow-util-agent" }]
}
+1 -1
View File
@@ -22,7 +22,7 @@
"test:ci": "bun test"
},
"dependencies": {
"@uncaged/json-cas": "^0.5.2",
"@uncaged/json-cas": "^0.5.3",
"@uncaged/workflow-util-agent": "workspace:^",
"@uncaged/workflow-util": "workspace:^"
},
+1 -1
View File
@@ -1,4 +1,5 @@
import type { Store } from "@uncaged/json-cas";
import { createLogger, generateUlid } from "@uncaged/workflow-util";
import {
type AgentContext,
type AgentRunResult,
@@ -7,7 +8,6 @@ import {
resolveModel,
resolveStorageRoot,
} from "@uncaged/workflow-util-agent";
import { createLogger, generateUlid } from "@uncaged/workflow-util";
import { storeBuiltinDetail } from "./detail.js";
import type { ChatMessage } from "./llm/index.js";
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ResolvedLlmProvider } from "@uncaged/workflow-util-agent";
import { createLogger } from "@uncaged/workflow-util";
import type { ResolvedLlmProvider } from "@uncaged/workflow-util-agent";
import {
type ChatMessage,
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import type { AgentContext } from "@uncaged/workflow-util-agent";
import type { ThreadId } from "@uncaged/workflow-protocol";
import type { AgentContext } from "@uncaged/workflow-util-agent";
import { buildClaudeCodePrompt } from "../src/claude-code.js";
function makeCtx(overrides: Partial<AgentContext> = {}): AgentContext {
@@ -22,7 +22,7 @@
"test:ci": "bun test"
},
"dependencies": {
"@uncaged/json-cas": "^0.5.2",
"@uncaged/json-cas": "^0.5.3",
"@uncaged/workflow-util-agent": "workspace:^",
"@uncaged/workflow-util": "workspace:^"
},
@@ -1,5 +1,6 @@
import { spawn } from "node:child_process";
import type { Store } from "@uncaged/json-cas";
import { createLogger } from "@uncaged/workflow-util";
import {
type AgentContext,
type AgentRunResult,
@@ -9,7 +10,6 @@ import {
getCachedSessionId,
setCachedSessionId,
} from "@uncaged/workflow-util-agent";
import { createLogger } from "@uncaged/workflow-util";
import { parseClaudeCodeStreamOutput, storeClaudeCodeDetail } from "./session-detail.js";
+13 -2
View File
@@ -1,10 +1,12 @@
# @uncaged/workflow-agent-hermes
`uwf-hermes` agent — spawns Hermes chat via ACP and captures session detail.
`uwf-hermes` — an **agent adapter** that bridges the `uwf` workflow engine and the Hermes CLI.
## 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.
`uwf-hermes` is an adapter (not the Hermes CLI itself). The `uwf` engine speaks a generic agent protocol (stdin/stdout frontmatter contract); `uwf-hermes` translates that protocol into Hermes ACP (Agent Client Protocol) calls. Other adapters (e.g. `uwf-claude-code`, `uwf-cursor`) do the same for their respective CLIs.
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-util-agent`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`
@@ -18,6 +20,15 @@ bun add -g @uncaged/workflow-agent-hermes
Requires the `hermes` CLI on `PATH`.
Hermes must write session JSON snapshots so `uwf-hermes` can load structured tool calls from disk. Add this to `~/.hermes/config.yaml`:
```yaml
sessions:
write_json_snapshots: true
```
Session files are stored at `~/.hermes/sessions/session_{sessionId}.json`.
## CLI Usage
Invoked by `uwf thread step` (not typically run directly):
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { HermesAcpClient } from "../src/acp-client.js";
describe("handleSessionUpdate — helper extraction", () => {
describe("handleSessionUpdate — text extraction", () => {
let client: HermesAcpClient;
beforeEach(() => {
@@ -14,82 +14,41 @@ describe("handleSessionUpdate — helper extraction", () => {
});
it("agent_message_chunk accumulates text in messageChunks", () => {
(client as any).handleSessionUpdate({
(
client as unknown as { handleSessionUpdate: (u: Record<string, unknown>) => void }
).handleSessionUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "hello" },
});
(client as any).handleSessionUpdate({
(
client as unknown as { handleSessionUpdate: (u: Record<string, unknown>) => void }
).handleSessionUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: " world" },
});
expect((client as any).messageChunks).toEqual(["hello", " world"]);
expect((client as unknown as { messageChunks: string[] }).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" },
it("non-text chunks and other update types are ignored", () => {
(
client as unknown as { handleSessionUpdate: (u: Record<string, unknown>) => void }
).handleSessionUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "image", text: "ignored" },
});
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({
(
client as unknown as { handleSessionUpdate: (u: Record<string, unknown>) => void }
).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);
(
client as unknown as { handleSessionUpdate: (u: Record<string, unknown>) => void }
).handleSessionUpdate({ sessionUpdate: "unknown_type", data: {} });
expect((client as unknown as { messageChunks: string[] }).messageChunks).toHaveLength(0);
});
});
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import type { AgentContext } from "@uncaged/workflow-util-agent";
import type { ThreadId } from "@uncaged/workflow-protocol";
import type { AgentContext } from "@uncaged/workflow-util-agent";
import { buildHermesPrompt } from "../src/hermes.js";
function makeCtx(overrides: Partial<AgentContext> = {}): AgentContext {
@@ -53,23 +53,4 @@ describe("HermesAcpClient", () => {
},
{ timeout: 2 * 60 * 1000 },
);
// TODO(#435): flaky — depends on live LLM; mock or move to integration suite
it.skip(
"prompt() collects structured messages including tool calls",
async () => {
await client.connect(process.cwd());
const result = await client.prompt("Run this command: echo TOOL_DETAIL_TEST");
expect(result.messages.length).toBeGreaterThan(0);
const toolMessages = result.messages.filter((m) => m.role === "tool");
expect(toolMessages.length).toBeGreaterThan(0);
const toolContent = toolMessages[0]?.content ?? "";
expect(toolContent).toContain("TOOL_DETAIL_TEST");
const assistantWithTools = result.messages.filter(
(m) => m.role === "assistant" && m.tool_calls !== null,
);
expect(assistantWithTools.length).toBeGreaterThan(0);
},
{ timeout: 2 * 60 * 1000 },
);
});
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it } from "bun:test";
import { HermesAcpClient } from "../src/acp-client.js";
import { HermesAcpClient } from "../../src/acp-client.js";
/**
* E2E test for cross-process session resume.
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const PKG_ROOT = join(import.meta.dir, "..");
describe("Issue #551 — bin entry & engines", () => {
test("package.json declares bun in engines", () => {
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf-8"));
expect(pkg.engines).toBeDefined();
expect(pkg.engines.bun).toBeDefined();
expect(pkg.engines.bun).toMatch(/^>=?\s*[\d.]+/);
});
test("bin entry file has bun shebang", () => {
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf-8"));
const binPath = pkg.bin["uwf-hermes"];
const content = readFileSync(join(PKG_ROOT, binPath), "utf-8");
expect(content.startsWith("#!/usr/bin/env bun")).toBe(true);
});
test("README.md explains uwf-hermes is an adapter", () => {
const readme = readFileSync(join(PKG_ROOT, "README.md"), "utf-8");
expect(readme.toLowerCase()).toContain("adapter");
expect(readme).toMatch(/uwf-hermes/);
expect(readme).toMatch(/hermes/);
});
});
@@ -1,9 +1,15 @@
import { Database } from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createMemoryStore, refs, validate, walk } from "@uncaged/json-cas";
import {
computeDurationMs,
extractLastAssistantContent,
getHermesDbPath,
loadHermesSessionFromDb,
messageToTurnPayload,
parseSessionIdFromStdout,
storeHermesSessionDetail,
@@ -124,3 +130,236 @@ describe("storeHermesSessionDetail", () => {
}
});
});
// ── SQLite fallback tests ──────────────────────────────────────────
function createTestDb(dbPath: string): Database {
const db = new Database(dbPath);
db.run(`CREATE TABLE sessions (
id TEXT PRIMARY KEY,
model TEXT NOT NULL,
started_at INTEGER NOT NULL
)`);
db.run(`CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT,
reasoning TEXT,
tool_calls TEXT,
FOREIGN KEY (session_id) REFERENCES sessions(id)
)`);
return db;
}
describe("getHermesDbPath", () => {
test("returns correct path", () => {
const { homedir } = require("node:os");
const { join } = require("node:path");
expect(getHermesDbPath()).toBe(join(homedir(), ".hermes", "state.db"));
});
});
describe("loadHermesSessionFromDb", () => {
test("returns session data from SQLite", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "hermes-test-"));
const dbPath = join(tmpDir, "state.db");
const db = createTestDb(dbPath);
const sessionId = "test-session-001";
const startedAt = 1748099519;
db.run("INSERT INTO sessions (id, model, started_at) VALUES (?, ?, ?)", [
sessionId,
"claude-opus-4.6",
startedAt,
]);
db.run(
"INSERT INTO messages (session_id, role, content, reasoning, tool_calls) VALUES (?, ?, ?, ?, ?)",
[sessionId, "user", "hello", null, null],
);
db.run(
"INSERT INTO messages (session_id, role, content, reasoning, tool_calls) VALUES (?, ?, ?, ?, ?)",
[sessionId, "assistant", "hi there", "thinking...", null],
);
db.close();
const result = await loadHermesSessionFromDb(sessionId, dbPath);
expect(result).not.toBeNull();
expect(result!.session_id).toBe(sessionId);
expect(result!.model).toBe("claude-opus-4.6");
expect(result!.messages).toHaveLength(2);
expect(result!.messages[0]!.role).toBe("user");
expect(result!.messages[0]!.content).toBe("hello");
expect(result!.messages[1]!.role).toBe("assistant");
expect(result!.messages[1]!.content).toBe("hi there");
expect(result!.messages[1]!.reasoning).toBe("thinking...");
await rm(tmpDir, { recursive: true });
});
test("returns null when no session exists in DB", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "hermes-test-"));
const dbPath = join(tmpDir, "state.db");
const db = createTestDb(dbPath);
db.close();
const result = await loadHermesSessionFromDb("nonexistent", dbPath);
expect(result).toBeNull();
await rm(tmpDir, { recursive: true });
});
test("returns null when DB file does not exist", async () => {
const result = await loadHermesSessionFromDb("any-id", "/tmp/nonexistent-hermes-db.db");
expect(result).toBeNull();
});
test("correctly parses tool_calls from DB JSON string", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "hermes-test-"));
const dbPath = join(tmpDir, "state.db");
const db = createTestDb(dbPath);
const sessionId = "test-tool-calls";
db.run("INSERT INTO sessions (id, model, started_at) VALUES (?, ?, ?)", [
sessionId,
"gpt-4",
1748099519,
]);
const toolCallsJson = JSON.stringify([
{ function: { name: "read_file", arguments: '{"path":"x"}' } },
]);
db.run(
"INSERT INTO messages (session_id, role, content, reasoning, tool_calls) VALUES (?, ?, ?, ?, ?)",
[sessionId, "assistant", "", null, toolCallsJson],
);
db.close();
const result = await loadHermesSessionFromDb(sessionId, dbPath);
expect(result).not.toBeNull();
expect(result!.messages[0]!.tool_calls).toEqual([
{ function: { name: "read_file", arguments: '{"path":"x"}' } },
]);
await rm(tmpDir, { recursive: true });
});
test("handles null fields in DB messages gracefully", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "hermes-test-"));
const dbPath = join(tmpDir, "state.db");
const db = createTestDb(dbPath);
const sessionId = "test-nulls";
db.run("INSERT INTO sessions (id, model, started_at) VALUES (?, ?, ?)", [
sessionId,
"model",
1748099519,
]);
db.run(
"INSERT INTO messages (session_id, role, content, reasoning, tool_calls) VALUES (?, ?, ?, ?, ?)",
[sessionId, "assistant", null, null, null],
);
db.close();
const result = await loadHermesSessionFromDb(sessionId, dbPath);
expect(result).not.toBeNull();
const msg = result!.messages[0]!;
expect(msg.content).toBeNull();
expect(msg.reasoning).toBeNull();
expect(msg.tool_calls).toBeNull();
await rm(tmpDir, { recursive: true });
});
test("messages ordered by insertion order", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "hermes-test-"));
const dbPath = join(tmpDir, "state.db");
const db = createTestDb(dbPath);
const sessionId = "test-order";
db.run("INSERT INTO sessions (id, model, started_at) VALUES (?, ?, ?)", [
sessionId,
"model",
1748099519,
]);
db.run(
"INSERT INTO messages (session_id, role, content, reasoning, tool_calls) VALUES (?, ?, ?, ?, ?)",
[sessionId, "user", "first", null, null],
);
db.run(
"INSERT INTO messages (session_id, role, content, reasoning, tool_calls) VALUES (?, ?, ?, ?, ?)",
[sessionId, "assistant", "second", null, null],
);
db.run(
"INSERT INTO messages (session_id, role, content, reasoning, tool_calls) VALUES (?, ?, ?, ?, ?)",
[sessionId, "user", "third", null, null],
);
db.close();
const result = await loadHermesSessionFromDb(sessionId, dbPath);
expect(result).not.toBeNull();
expect(result!.messages.map((m) => m.content)).toEqual(["first", "second", "third"]);
await rm(tmpDir, { recursive: true });
});
test("converts unix timestamp to ISO string for session_start", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "hermes-test-"));
const dbPath = join(tmpDir, "state.db");
const db = createTestDb(dbPath);
const sessionId = "test-timestamp";
const startedAt = 1748099519;
db.run("INSERT INTO sessions (id, model, started_at) VALUES (?, ?, ?)", [
sessionId,
"model",
startedAt,
]);
db.close();
const result = await loadHermesSessionFromDb(sessionId, dbPath);
expect(result).not.toBeNull();
expect(result!.session_start).toBe(new Date(startedAt * 1000).toISOString());
await rm(tmpDir, { recursive: true });
});
});
describe("loadHermesSession with SQLite fallback", () => {
test("JSON file takes priority over DB", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "hermes-test-"));
const dbPath = join(tmpDir, "state.db");
const jsonPath = join(tmpDir, "session.json");
// Create DB with one model value
const db = createTestDb(dbPath);
const sessionId = "test-priority";
db.run("INSERT INTO sessions (id, model, started_at) VALUES (?, ?, ?)", [
sessionId,
"db-model",
1748099519,
]);
db.run(
"INSERT INTO messages (session_id, role, content, reasoning, tool_calls) VALUES (?, ?, ?, ?, ?)",
[sessionId, "user", "from db", null, null],
);
db.close();
// Create JSON file with a different model value
const jsonData: HermesSessionJson = {
session_id: sessionId,
model: "json-model",
session_start: "2026-05-24T12:00:00.000Z",
messages: [{ role: "user", content: "from json", reasoning: null, tool_calls: null }],
};
await writeFile(jsonPath, JSON.stringify(jsonData));
// loadHermesSession reads from JSON path, so we test the existing function directly
// The JSON-first priority is inherent in the implementation
const { readFile } = await import("node:fs/promises");
const text = await readFile(jsonPath, "utf8");
const parsed = JSON.parse(text);
expect(parsed.model).toBe("json-model");
await rm(tmpDir, { recursive: true });
});
});
+5 -2
View File
@@ -19,10 +19,10 @@
},
"scripts": {
"test": "bun test",
"test:ci": "bun test __tests__/*.test.ts"
"test:ci": "bun test __tests__/*.test.ts"
},
"dependencies": {
"@uncaged/json-cas": "^0.5.2",
"@uncaged/json-cas": "^0.5.3",
"@uncaged/workflow-util-agent": "workspace:^",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^"
@@ -42,5 +42,8 @@
"bugs": {
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
},
"engines": {
"bun": ">= 1.0.0"
},
"license": "MIT"
}
@@ -2,8 +2,6 @@ import type { ChildProcess } from "node:child_process";
import { spawn } from "node:child_process";
import { createInterface } from "node:readline";
import type { HermesSessionMessage } from "./types.js";
const HERMES_COMMAND = "hermes";
const PROTOCOL_VERSION = 1;
@@ -19,16 +17,9 @@ type PendingRequest = {
reject: (reason: Error) => void;
};
/** Tracks in-flight tool calls so we can build complete messages when they finish. */
type PendingToolCall = {
name: string;
args: string;
};
export type AcpPromptResult = {
text: string;
sessionId: string;
messages: HermesSessionMessage[];
};
export class HermesAcpClient {
@@ -38,11 +29,8 @@ export class HermesAcpClient {
private stderrBuffer = "";
private pending = new Map<number, PendingRequest>();
// Message collection state
/** Accumulated assistant text chunks from agent_message_chunk updates. */
private messageChunks: string[] = [];
private reasoningChunks: string[] = [];
private pendingTools = new Map<string, PendingToolCall>();
messages: HermesSessionMessage[] = [];
/** Spawn hermes acp, initialize, create session */
async connect(cwd: string): Promise<string> {
@@ -84,14 +72,13 @@ export class HermesAcpClient {
return sessionId;
}
/** Send prompt and collect full response text + structured messages. */
/** Send prompt and collect final assistant text from ACP stream chunks. */
async prompt(text: string): Promise<AcpPromptResult> {
if (this.sessionId === null) {
throw new Error("Not connected — call connect() first");
}
this.messageChunks = [];
this.reasoningChunks = [];
const response = await this.sendRequest("session/prompt", {
sessionId: this.sessionId,
@@ -104,28 +91,9 @@ export class HermesAcpClient {
);
}
// Flush any trailing assistant text that wasn't followed by a tool call.
this.flushAssistantMessage();
// Extract the final assistant text from collected messages.
let finalText = "";
for (let i = this.messages.length - 1; i >= 0; i--) {
const msg = this.messages[i];
if (
msg !== undefined &&
msg.role === "assistant" &&
msg.content !== null &&
msg.content.trim() !== ""
) {
finalText = msg.content;
break;
}
}
return {
text: finalText,
text: this.messageChunks.join(""),
sessionId: this.sessionId,
messages: this.messages,
};
}
@@ -242,94 +210,16 @@ export class HermesAcpClient {
}
}
// ---- Session update → structured messages ----
private handleSessionUpdate(update: Record<string, unknown>): void {
switch (update.sessionUpdate as string) {
case "agent_message_chunk":
this.handleAgentMessageChunk(update);
break;
case "agent_thought_chunk":
this.handleAgentThoughtChunk(update);
break;
case "tool_call":
this.handleToolCall(update);
break;
case "tool_call_update":
this.handleToolCallUpdate(update);
break;
default:
break;
if (update.sessionUpdate !== "agent_message_chunk") {
return;
}
}
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("");
const reasoning = this.reasoningChunks.join("");
if (text !== "" || reasoning !== "") {
this.messages.push({
role: "assistant",
content: text || null,
reasoning: reasoning || null,
tool_calls: null,
});
}
this.messageChunks = [];
this.reasoningChunks = [];
}
private rejectAll(err: Error): void {
for (const handler of this.pending.values()) {
handler.reject(err);
+11 -17
View File
@@ -1,4 +1,5 @@
import type { Store } from "@uncaged/json-cas";
import { createLogger } from "@uncaged/workflow-util";
import {
type AgentContext,
type AgentRunResult,
@@ -6,11 +7,10 @@ import {
buildRolePrompt,
createAgent,
} from "@uncaged/workflow-util-agent";
import { createLogger } from "@uncaged/workflow-util";
import { HermesAcpClient } from "./acp-client.js";
import { getCachedSessionId, isResumeDisabled, setCachedSessionId } from "./session-cache.js";
import { storeHermesSessionDetail } from "./session-detail.js";
import { loadHermesSession, storeHermesSessionDetail } from "./session-detail.js";
const log = createLogger({ sink: { kind: "stderr" } });
@@ -49,17 +49,11 @@ export function buildHermesPrompt(ctx: AgentContext): string {
return parts.join("\n");
}
async function storePromptResult(
store: Store,
sessionId: string,
messages: Awaited<ReturnType<HermesAcpClient["prompt"]>>["messages"],
): Promise<{ detailHash: string }> {
const session = {
session_id: sessionId,
model: "",
session_start: new Date().toISOString(),
messages,
};
async function storePromptResult(store: Store, sessionId: string): Promise<{ detailHash: string }> {
const session = await loadHermesSession(sessionId);
if (session === null) {
throw new Error(`Hermes session file not found: ${sessionId}`);
}
return storeHermesSessionDetail(store, session);
}
@@ -116,8 +110,8 @@ export function createHermesAgent(): () => Promise<void> {
async function runPrompt(ctx: AgentContext, useContinuation: boolean): Promise<AgentRunResult> {
const effectiveCtx = useContinuation ? ctx : { ...ctx, isFirstVisit: true };
const fullPrompt = buildHermesPrompt(effectiveCtx);
const { text, sessionId, messages } = await client.prompt(fullPrompt);
const { detailHash } = await storePromptResult(ctx.store, sessionId, messages);
const { text, sessionId } = await client.prompt(fullPrompt);
const { detailHash } = await storePromptResult(ctx.store, sessionId);
if (!isResumeDisabled()) {
await setCachedSessionId(ctx.threadId, ctx.role, sessionId);
@@ -152,8 +146,8 @@ export function createHermesAgent(): () => Promise<void> {
): Promise<AgentRunResult> {
// Client is already connected from runHermes — same ACP session,
// so the agent sees the full conversation history (crucial for retries).
const { text, sessionId, messages } = await client.prompt(message);
const { detailHash } = await storePromptResult(store, sessionId, messages);
const { text, sessionId } = await client.prompt(message);
const { detailHash } = await storePromptResult(store, sessionId);
return { output: text, detailHash, sessionId };
}
@@ -1,10 +1,10 @@
// Re-export session cache from the shared agent-kit package with agent name injected.
import type { ThreadId } from "@uncaged/workflow-protocol";
import {
getCachedSessionId as getCachedSessionIdBase,
setCachedSessionId as setCachedSessionIdBase,
} from "@uncaged/workflow-util-agent";
import type { ThreadId } from "@uncaged/workflow-protocol";
export async function getCachedSessionId(threadId: ThreadId, role: string): Promise<string | null> {
return getCachedSessionIdBase("hermes", threadId, role);
@@ -1,3 +1,4 @@
import { Database } from "bun:sqlite";
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
@@ -108,15 +109,99 @@ function parseSessionJson(raw: unknown): HermesSessionJson | null {
return { session_id, model, session_start, messages };
}
export function getHermesDbPath(): string {
return join(homedir(), ".hermes", "state.db");
}
type DbSessionRow = {
id: string;
model: string;
started_at: number;
};
type DbMessageRow = {
role: string;
content: string | null;
reasoning: string | null;
tool_calls: string | null;
};
function parseDbToolCalls(raw: string | null): HermesSessionMessage["tool_calls"] {
if (raw === null) {
return null;
}
try {
const parsed = JSON.parse(raw) as unknown;
return parseToolCalls(parsed);
} catch {
return null;
}
}
function dbMessageToSessionMessage(row: DbMessageRow): HermesSessionMessage {
return {
role: row.role,
content: row.content ?? null,
reasoning: row.reasoning ?? null,
tool_calls: parseDbToolCalls(row.tool_calls),
};
}
export function loadHermesSessionFromDb(
sessionId: string,
dbPath: string | null = null,
): HermesSessionJson | null {
const resolvedPath = dbPath ?? getHermesDbPath();
let db: InstanceType<typeof Database> | null = null;
try {
db = new Database(resolvedPath, { readonly: true });
const session = db
.query("SELECT id, model, started_at FROM sessions WHERE id = ?")
.get(sessionId) as DbSessionRow | null;
if (session === null) {
return null;
}
const rows = db
.query(
"SELECT role, content, reasoning, tool_calls FROM messages WHERE session_id = ? ORDER BY id",
)
.all(sessionId) as DbMessageRow[];
const messages: HermesSessionMessage[] = [];
for (const row of rows) {
const role = row.role;
if (role !== "user" && role !== "assistant" && role !== "tool") {
continue;
}
messages.push(dbMessageToSessionMessage(row));
}
return {
session_id: session.id,
model: session.model,
session_start: new Date(session.started_at * 1000).toISOString(),
messages,
};
} catch {
return null;
} finally {
db?.close();
}
}
export async function loadHermesSession(sessionId: string): Promise<HermesSessionJson | null> {
const path = getHermesSessionPath(sessionId);
try {
const text = await readFile(path, "utf8");
const raw = JSON.parse(text) as unknown;
return parseSessionJson(raw);
const result = parseSessionJson(raw);
if (result !== null) {
return result;
}
} catch {
return null;
// JSON file not available, fall through to DB
}
return loadHermesSessionFromDb(sessionId);
}
export function computeDurationMs(sessionStart: string, nowMs: number = Date.now()): number {
+1 -1
View File
@@ -100,7 +100,7 @@ type ProviderAlias = string;
type ModelAlias = string;
type AgentAlias = string;
type ProviderConfig = { baseUrl: string; apiKeyEnv: string };
type ProviderConfig = { baseUrl: string; apiKey: string };
type ModelConfig = {
provider: ProviderAlias;
name: string;
+2 -2
View File
@@ -15,8 +15,8 @@
}
},
"dependencies": {
"@uncaged/json-cas": "^0.5.2",
"@uncaged/json-cas-fs": "^0.5.2"
"@uncaged/json-cas": "^0.5.3",
"@uncaged/json-cas-fs": "^0.5.3"
},
"devDependencies": {
"typescript": "^5.8.3"
@@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test";
import type { StartNodePayload, StepRecord, Target } from "../types.js";
describe("Protocol types for thread/edge location", () => {
describe("StartNodePayload", () => {
test("has required cwd field", () => {
const payload: StartNodePayload = {
workflow: "0123456789ABC",
prompt: "Test prompt",
cwd: "/home/user/project",
};
expect(payload.cwd).toBe("/home/user/project");
expect(typeof payload.cwd).toBe("string");
});
});
describe("StepRecord", () => {
test("has required cwd field", () => {
const record: StepRecord = {
role: "planner",
output: "0123456789ABC",
detail: "DEF0123456789",
agent: "uwf-hermes",
edgePrompt: "Plan the implementation",
startedAtMs: Date.now(),
completedAtMs: Date.now() + 1000,
cwd: "/home/user/project",
};
expect(record.cwd).toBe("/home/user/project");
expect(typeof record.cwd).toBe("string");
});
});
describe("Target", () => {
test("has location field that accepts string", () => {
const target: Target = {
role: "coder",
prompt: "Implement the code",
location: "/custom/path",
};
expect(target.location).toBe("/custom/path");
expect(typeof target.location).toBe("string");
});
test("has location field that accepts null", () => {
const target: Target = {
role: "coder",
prompt: "Implement the code",
location: null,
};
expect(target.location).toBe(null);
});
test("location supports mustache template syntax", () => {
const target: Target = {
role: "coder",
prompt: "Implement the code",
location: "{{{repoPath}}}",
};
expect(target.location).toBe("{{{repoPath}}}");
});
});
});
+1
View File
@@ -29,6 +29,7 @@ export type {
ThreadForkOutput,
ThreadId,
ThreadListItem,
ThreadStatus,
ThreadStepsOutput,
ThreadsIndex,
WorkflowConfig,
+17 -2
View File
@@ -20,6 +20,9 @@ const TARGET: JSONSchema = {
properties: {
role: { type: "string" },
prompt: { type: "string" },
location: {
anyOf: [{ type: "string" }, { type: "null" }],
},
},
additionalProperties: false,
};
@@ -49,10 +52,11 @@ export const WORKFLOW_SCHEMA: JSONSchema = {
export const START_NODE_SCHEMA: JSONSchema = {
title: "StartNode",
type: "object",
required: ["workflow", "prompt"],
required: ["workflow", "prompt", "cwd"],
properties: {
workflow: { type: "string", format: "cas_ref" },
prompt: { type: "string" },
cwd: { type: "string" },
},
additionalProperties: false,
};
@@ -60,7 +64,17 @@ export const START_NODE_SCHEMA: JSONSchema = {
export const STEP_NODE_SCHEMA: JSONSchema = {
title: "StepNode",
type: "object",
required: ["start", "prev", "role", "output", "detail", "agent", "startedAtMs", "completedAtMs"],
required: [
"start",
"prev",
"role",
"output",
"detail",
"agent",
"startedAtMs",
"completedAtMs",
"cwd",
],
properties: {
start: { type: "string", format: "cas_ref" },
prev: {
@@ -73,6 +87,7 @@ export const STEP_NODE_SCHEMA: JSONSchema = {
edgePrompt: { type: "string" },
startedAtMs: { type: "integer" },
completedAtMs: { type: "integer" },
cwd: { type: "string" },
},
additionalProperties: false,
};
+18 -2
View File
@@ -18,6 +18,8 @@ export type StepRecord = {
startedAtMs: number;
/** Date.now() after agent returns */
completedAtMs: number;
/** Working directory where the agent executed. Missing in legacy nodes → "". */
cwd: string;
};
// ── 4.2 Workflow 定义 ───────────────────────────────────────────────
@@ -34,6 +36,8 @@ export type RoleDefinition = {
export type Target = {
role: string;
prompt: string;
/** Optional working directory override via mustache template. */
location: string | null;
};
export type WorkflowPayload = {
@@ -48,6 +52,8 @@ export type WorkflowPayload = {
export type StartNodePayload = {
workflow: CasRef;
prompt: string;
/** Working directory where the thread was created. */
cwd: string;
};
export type StepNodePayload = StepRecord & {
@@ -70,17 +76,27 @@ export type ModeratorContext = {
// ── 4.5 CLI 输出 ────────────────────────────────────────────────────
/** Thread status — unified status representation */
export type ThreadStatus = "idle" | "running" | "completed" | "cancelled";
/** uwf thread start */
export type StartOutput = {
workflow: CasRef;
thread: ThreadId;
};
/** uwf thread step / uwf thread show */
/**
* Output from thread show and thread exec commands.
*
* @property status - Current thread status (idle/running/completed/cancelled)
* @property done - @deprecated Use status field instead. True if thread is completed or cancelled.
* @property background - @deprecated Use status field instead. Always null in current implementation.
*/
export type StepOutput = {
workflow: CasRef;
thread: ThreadId;
head: CasRef;
status: ThreadStatus;
done: boolean;
background: boolean | null;
};
@@ -151,7 +167,7 @@ export type Scenario = string;
export type ProviderConfig = {
baseUrl: string;
apiKeyEnv: string;
apiKey: string;
};
export type ModelConfig = {
@@ -0,0 +1,72 @@
import { createMemoryStore, putSchema } from "@uncaged/json-cas";
import { describe, expect, test } from "vitest";
import { tryFrontmatterFastPath } from "../src/frontmatter.js";
// ── Helpers ───────────────────────────────────────────────────────────────────
const PLANNER_SCHEMA = {
type: "object",
properties: {
$status: { type: "string", enum: ["ready", "failed"] },
plan: { type: "string" },
},
required: ["$status"],
additionalProperties: false,
};
describe("adapter-stdout: A4 retry loop survives JSON output", () => {
test("A4. first extraction fails, second succeeds — final result has correct data", async () => {
const store = createMemoryStore();
const schemaHash = await putSchema(store, PLANNER_SCHEMA);
// Simulate the retry loop from createAgent (run.ts lines 163-173):
// First attempt: agent outputs garbage (no frontmatter)
const badOutput = "Here is my response without frontmatter.\nJust plain text.";
const firstAttempt = await tryFrontmatterFastPath(badOutput, schemaHash, store);
expect(firstAttempt).toBeNull();
// Second attempt (after correction message): agent outputs valid frontmatter
const goodOutput = `---\n$status: ready\nplan: corrected-hash\n---\nCorrected body with valid frontmatter.`;
const secondAttempt = await tryFrontmatterFastPath(goodOutput, schemaHash, store);
expect(secondAttempt).not.toBeNull();
expect(secondAttempt!.outputHash).toMatch(/^[0-9A-Z]{13}$/);
expect(secondAttempt!.frontmatter).toEqual({ $status: "ready", plan: "corrected-hash" });
expect(secondAttempt!.body).toBe("Corrected body with valid frontmatter.");
// Verify the final AdapterOutput shape would be correct
const adapterOutput = {
stepHash: "MOCK_STEP_HASH",
detailHash: "MOCK_DETAIL_HA",
role: "planner",
frontmatter: secondAttempt!.frontmatter,
body: secondAttempt!.body,
startedAtMs: 1000,
completedAtMs: 2000,
};
const json = JSON.stringify(adapterOutput);
const parsed = JSON.parse(json);
expect(parsed.frontmatter).toEqual({ $status: "ready", plan: "corrected-hash" });
expect(parsed.body).toBe("Corrected body with valid frontmatter.");
expect(parsed.completedAtMs).toBeGreaterThanOrEqual(parsed.startedAtMs);
});
test("A4. all retries fail — extraction returns null on every attempt", async () => {
const store = createMemoryStore();
const schemaHash = await putSchema(store, PLANNER_SCHEMA);
const MAX_RETRIES = 2;
const badOutput = "No frontmatter here";
// Simulate MAX_FRONTMATTER_RETRIES iterations all failing
let extracted = await tryFrontmatterFastPath(badOutput, schemaHash, store);
for (let retry = 0; retry < MAX_RETRIES && extracted === null; retry++) {
// Each retry also gets bad output
extracted = await tryFrontmatterFastPath(badOutput, schemaHash, store);
}
expect(extracted).toBeNull();
});
});
@@ -0,0 +1,105 @@
import { createMemoryStore, putSchema } from "@uncaged/json-cas";
import { describe, expect, test } from "vitest";
import { tryFrontmatterFastPath } from "../src/frontmatter.js";
// ── Helpers ───────────────────────────────────────────────────────────────────
const PLANNER_SCHEMA = {
type: "object",
properties: {
$status: { type: "string", enum: ["ready", "failed"] },
plan: { type: "string" },
},
required: ["$status"],
additionalProperties: false,
};
const FRONTMATTER_SCHEMA = {
type: "object",
properties: {
status: { anyOf: [{ type: "string" }, { type: "null" }] },
next: { anyOf: [{ type: "string" }, { type: "null" }] },
confidence: { anyOf: [{ type: "number" }, { type: "null" }] },
artifacts: { type: "array", items: { type: "string" } },
scope: { type: "string" },
},
required: ["status", "next", "confidence", "artifacts", "scope"],
additionalProperties: false,
};
describe("adapter-stdout: FrontmatterFastPathResult includes frontmatter", () => {
test("A2. frontmatter field contains the parsed YAML frontmatter object", async () => {
const store = createMemoryStore();
const schemaHash = await putSchema(store, PLANNER_SCHEMA);
const raw = `---\n$status: ready\nplan: abc123\n---\nSome body text`;
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).not.toBeNull();
expect(result!.frontmatter).toEqual({ $status: "ready", plan: "abc123" });
});
test("A3. body field contains the markdown body after frontmatter", async () => {
const store = createMemoryStore();
const schemaHash = await putSchema(store, PLANNER_SCHEMA);
const raw = `---\n$status: ready\nplan: hash123\n---\nHere is the body.\n\nWith multiple paragraphs.`;
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).not.toBeNull();
expect(result!.body).toBe("Here is the body.\n\nWith multiple paragraphs.");
});
test("A1. result contains outputHash as valid CasRef", async () => {
const store = createMemoryStore();
const schemaHash = await putSchema(store, FRONTMATTER_SCHEMA);
const raw = `---\nstatus: done\nnext: null\nconfidence: 0.9\nartifacts: []\nscope: test\n---\nBody`;
const result = await tryFrontmatterFastPath(raw, schemaHash, store);
expect(result).not.toBeNull();
expect(result!.outputHash).toMatch(/^[0-9A-Z]{13}$/);
expect(result!.frontmatter).toBeDefined();
expect(result!.body).toBe("Body");
});
});
describe("adapter-stdout: AdapterOutput JSON shape", () => {
test("A5. JSON.stringify produces valid parseable JSON with all fields", () => {
const output = {
stepHash: "0123456789ABC",
detailHash: "DEFGH12345678",
role: "planner",
frontmatter: { $status: "ready", plan: "somehash" },
body: "Plan body text",
startedAtMs: 1000,
completedAtMs: 2000,
};
const json = JSON.stringify(output);
const parsed = JSON.parse(json);
expect(parsed.stepHash).toBe("0123456789ABC");
expect(parsed.detailHash).toBe("DEFGH12345678");
expect(parsed.role).toBe("planner");
expect(parsed.frontmatter).toEqual({ $status: "ready", plan: "somehash" });
expect(parsed.body).toBe("Plan body text");
expect(parsed.startedAtMs).toBe(1000);
expect(parsed.completedAtMs).toBe(2000);
});
test("completedAtMs >= startedAtMs", () => {
const output = {
stepHash: "0123456789ABC",
detailHash: "DEFGH12345678",
role: "planner",
frontmatter: {},
body: "",
startedAtMs: 1000,
completedAtMs: 2000,
};
expect(output.completedAtMs).toBeGreaterThanOrEqual(output.startedAtMs);
});
});
+2 -2
View File
@@ -19,8 +19,8 @@
"test:ci": "bun test"
},
"dependencies": {
"@uncaged/json-cas": "^0.5.2",
"@uncaged/json-cas-fs": "^0.5.2",
"@uncaged/json-cas": "^0.5.3",
"@uncaged/json-cas-fs": "^0.5.3",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"dotenv": "^16.6.1",
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
describe("parseArgv empty prompt error message", () => {
let stderrOutput: string;
let _exitCode: number | null;
const originalExit = process.exit;
const originalStderrWrite = process.stderr.write;
beforeEach(() => {
stderrOutput = "";
_exitCode = null;
process.exit = ((code?: number) => {
_exitCode = code ?? 1;
throw new Error("process.exit called");
}) as any;
process.stderr.write = ((chunk: string) => {
stderrOutput += chunk;
return true;
}) as any;
});
afterEach(() => {
process.exit = originalExit;
process.stderr.write = originalStderrWrite;
});
test("empty prompt produces error message mentioning template variables", async () => {
const { parseArgv } = await import("../run.js");
const argv = [
"node",
"uwf-hermes",
"--thread",
"01ABCDEFGHIJKLMNOPQRSTUVWX",
"--role",
"classifier",
"--prompt",
"",
];
expect(() => parseArgv(argv)).toThrow("process.exit called");
expect(stderrOutput).toContain("prompt");
expect(stderrOutput).toContain("empty");
expect(stderrOutput).toContain("template");
});
});
@@ -130,6 +130,7 @@ async function buildHistory(
edgePrompt: step.edgePrompt ?? "",
startedAtMs: step.startedAtMs,
completedAtMs: step.completedAtMs,
cwd: step.cwd ?? "",
content,
});
}
+4 -6
View File
@@ -1,8 +1,7 @@
import { getSchema, validate } from "@uncaged/json-cas";
import type { CasRef, ModelAlias, WorkflowConfig } from "@uncaged/workflow-protocol";
import { config as loadDotenv } from "dotenv";
import { createAgentStore, getEnvPath, resolveStorageRoot } from "./storage.js";
import { createAgentStore, resolveStorageRoot } from "./storage.js";
export type ResolvedLlmProvider = {
baseUrl: string;
@@ -38,9 +37,9 @@ export function resolveModel(config: WorkflowConfig, alias: ModelAlias): Resolve
if (providerEntry === undefined) {
throw new Error(`unknown provider "${modelEntry.provider}" for model "${alias}"`);
}
const apiKey = process.env[providerEntry.apiKeyEnv];
const apiKey = providerEntry.apiKey;
if (apiKey === undefined || apiKey === "") {
throw new Error(`missing API key env var: ${providerEntry.apiKeyEnv}`);
throw new Error(`missing API key for provider: ${modelEntry.provider}`);
}
return {
baseUrl: providerEntry.baseUrl,
@@ -130,7 +129,7 @@ export type ExtractResult = {
/**
* Call an OpenAI-compatible LLM to extract structured output matching outputSchema.
* Loads config.yaml and .env from the workflow storage root.
* Loads config.yaml from the workflow storage root.
*/
export async function extract(
rawOutput: string,
@@ -138,7 +137,6 @@ export async function extract(
config: WorkflowConfig,
): Promise<ExtractResult> {
const storageRoot = resolveStorageRoot();
loadDotenv({ path: getEnvPath(storageRoot) });
const { store } = await createAgentStore(storageRoot);
const schema = getSchema(store, outputSchema);
@@ -20,6 +20,7 @@ type StandardKey = (typeof STANDARD_KEYS)[number];
export type FrontmatterFastPathResult = {
body: string;
outputHash: CasRef;
frontmatter: Record<string, unknown>;
};
function extractYamlBlock(raw: string): string | null {
@@ -191,5 +192,5 @@ export async function tryFrontmatterFastPath(
return null;
}
return { body, outputHash };
return { body, outputHash, frontmatter: candidate };
}
+2 -1
View File
@@ -11,10 +11,11 @@ export {
} from "./extract.js";
export type { FrontmatterFastPathResult } from "./frontmatter.js";
export { tryFrontmatterFastPath } from "./frontmatter.js";
export { createAgent } from "./run.js";
export { createAgent, parseArgv } from "./run.js";
export { getCachedSessionId, getCachePath, setCachedSessionId } from "./session-cache.js";
export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
export type {
AdapterOutput,
AgentContext,
AgentContinueFn,
AgentOptions,
+34 -11
View File
@@ -6,7 +6,7 @@ import { buildContextWithMeta } from "./context.js";
import { tryFrontmatterFastPath } from "./frontmatter.js";
import type { AgentStore } from "./storage.js";
import { getEnvPath, resolveStorageRoot } from "./storage.js";
import type { AgentOptions } from "./types.js";
import type { AdapterOutput, AgentOptions } from "./types.js";
const MAX_FRONTMATTER_RETRIES = 2;
@@ -32,13 +32,16 @@ function getNamedArg(argv: string[], name: string): string {
return argv[idx + 1];
}
function parseArgv(argv: string[]): { threadId: ThreadId; role: string; prompt: string } {
export 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);
if (prompt === "")
fail(
`--prompt is empty. If this agent was spawned by uwf, the edge prompt template may have unresolved variables. ${USAGE}`,
);
return { threadId: threadId as ThreadId, role, prompt };
}
@@ -72,6 +75,7 @@ async function writeStepNode(options: {
edgePrompt: options.edgePrompt,
startedAtMs: options.startedAtMs,
completedAtMs: options.completedAtMs,
cwd: process.cwd(),
};
const hash = await options.store.put(options.schemas.stepNode, payload);
const node = options.store.get(hash);
@@ -81,14 +85,24 @@ async function writeStepNode(options: {
return hash;
}
type ExtractedOutput = {
outputHash: CasRef;
frontmatter: Record<string, unknown>;
body: string;
};
async function tryExtractOutput(
rawOutput: string,
outputSchema: CasRef,
ctx: Awaited<ReturnType<typeof buildContextWithMeta>>,
): Promise<CasRef | null> {
): Promise<ExtractedOutput | null> {
const fastPath = await tryFrontmatterFastPath(rawOutput, outputSchema, ctx.meta.store);
if (fastPath !== null) {
return fastPath.outputHash;
return {
outputHash: fastPath.outputHash,
frontmatter: fastPath.frontmatter,
body: fastPath.body,
};
}
return null;
}
@@ -144,9 +158,9 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
const primaryDetailHash = agentResult.detailHash;
// Try to extract frontmatter; retry via continue if it fails
let outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
let extracted = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && outputHash === null; retry++) {
for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && extracted === null; retry++) {
const correctionMessage =
"Your previous response did not contain valid YAML frontmatter matching the role schema.\n" +
"You MUST begin your response with a YAML frontmatter block (--- delimited).\n" +
@@ -155,10 +169,10 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
agentResult = await runWithMessage("agent continue failed", () =>
options.continue(agentResult.sessionId, correctionMessage, ctx.meta.store),
);
outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
extracted = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
}
if (outputHash === null) {
if (extracted === null) {
fail(
"Agent output does not contain valid YAML frontmatter matching the role schema " +
`after ${MAX_FRONTMATTER_RETRIES} retries.\n` +
@@ -168,13 +182,22 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
const completedAtMs = Date.now();
const stepHash = await persistStep({
ctx,
outputHash,
outputHash: extracted.outputHash,
detailHash: primaryDetailHash,
agentName: agentLabel(options.name),
startedAtMs,
completedAtMs,
});
process.stdout.write(`${stepHash}\n`);
const adapterOutput: AdapterOutput = {
stepHash,
detailHash: primaryDetailHash,
role,
frontmatter: extracted.frontmatter,
body: extracted.body,
startedAtMs,
completedAtMs,
};
process.stdout.write(`${JSON.stringify(adapterOutput)}\n`);
};
}
+4 -4
View File
@@ -84,11 +84,11 @@ function normalizeProviders(raw: unknown): Record<ProviderAlias, ProviderConfig>
throw new Error(`config.providers.${name} must be a mapping`);
}
const baseUrl = entry.baseUrl;
const apiKeyEnv = entry.apiKeyEnv;
if (typeof baseUrl !== "string" || typeof apiKeyEnv !== "string") {
throw new Error(`config.providers.${name} requires baseUrl and apiKeyEnv`);
const apiKey = entry.apiKey;
if (typeof baseUrl !== "string" || typeof apiKey !== "string") {
throw new Error(`config.providers.${name} requires baseUrl and apiKey`);
}
providers[name] = { baseUrl, apiKeyEnv };
providers[name] = { baseUrl, apiKey };
}
return providers;
}
+10
View File
@@ -37,6 +37,16 @@ export type AgentContinueFn = (
export type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
export type AdapterOutput = {
stepHash: string;
detailHash: string;
role: string;
frontmatter: Record<string, unknown>;
body: string;
startedAtMs: number;
completedAtMs: number;
};
export type AgentOptions = {
name: string;
run: AgentRunFn;
-78
View File
@@ -1,78 +0,0 @@
# @uncaged/workflow-util
## 0.5.0-alpha.4
### Patch Changes
- Replace optionalEnv/requireEnv with unified env(name, fallback) API
- Updated dependencies [f74b482]
- Updated dependencies [f74b482]
- @uncaged/workflow-protocol@0.5.0-alpha.4
## 0.5.0-alpha.3
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.5.0-alpha.3
## 0.5.0-alpha.2
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.5.0-alpha.2
## 0.5.0-alpha.1
### Patch Changes
- @uncaged/workflow-protocol@0.5.0-alpha.1
## 0.5.0-alpha.0
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.5.0-alpha.0
## 0.4.5
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.4.5
## 0.4.4
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.4.4
## 0.4.3
### Patch Changes
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
- Updated dependencies
- @uncaged/workflow-protocol@0.4.3
## 0.4.2
### Patch Changes
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
- Updated dependencies
- @uncaged/workflow-protocol@0.4.2
## 0.4.0
### Minor Changes
- Fix package exports for published packages and adopt changesets for version management.
### Patch Changes
- Updated dependencies
- @uncaged/workflow-protocol@0.4.0
@@ -0,0 +1,68 @@
export function generateActorReference(): string {
return `# Actor Reference
You are executing a workflow role. Your system prompt defines your goal, procedure, and output requirements. This reference covers two things you need to know about the workflow engine.
## 1. Frontmatter Output Protocol
Your response **MUST** begin with a YAML frontmatter block at byte position 0 no preamble text before it.
\`\`\`
---
status: done
myField: some value
---
... markdown body (your work, explanation, notes) ...
\`\`\`
### Standard Field
| Field | Values | Default | Description |
|-------|--------|---------|-------------|
| \`status\` | \`done\`, \`needs_input\`, \`in_progress\`, \`failed\` | \`done\` | Completion signal — determines which graph edge the moderator follows next |
### Schema-Defined Fields
Your role's output schema (shown in the system prompt under "Deliverable Format") defines additional fields. Output **only** the fields listed there do not invent extra fields.
### Body
Everything after the closing \`---\` fence is the markdown body. Use it for explanations, logs, or human-readable notes. The body is stored but not parsed by the engine.
### Retry
If the engine cannot parse your frontmatter, it will ask you to retry (up to 2 times). Just output the corrected frontmatter block don't panic.
## 2. CAS (Content-Addressable Store)
Your frontmatter output is automatically stored in CAS. You can also **use CAS directly** to store intermediate artifacts, build merkle DAGs for large outputs, or reference data from previous steps.
### Commands
\`\`\`
uwf cas put-text <text> # store plain text, print hash
uwf cas put <type-hash> <json> # store typed JSON data, print hash
uwf cas get <hash> # read a CAS node (type + payload)
uwf cas has <hash> # check if a hash exists
uwf cas refs <hash> # list direct references from a node
uwf cas walk <hash> # recursive traversal from a node
uwf cas schema list # list registered schemas
uwf cas schema get <hash> # show a schema definition
\`\`\`
### Merkle DAG Pattern
For large outputs, store parts individually and reference their hashes:
\`\`\`bash
# Store individual sections
HASH1=$(uwf cas put-text "section 1 content")
HASH2=$(uwf cas put-text "section 2 content")
# Reference hashes in your frontmatter or in a parent node
\`\`\`
This enables progressive loading consumers can fetch the root and resolve children on demand.
`;
}
@@ -0,0 +1,163 @@
export function generateAdapterReference(): string {
return `# Adapter Reference
Guide for building a new agent adapter (CLI binary) for the workflow engine.
## What Is an Adapter
An adapter is a CLI command (e.g. \`uwf-hermes\`, \`uwf-builtin\`) that the engine spawns to execute a role. It bridges the workflow engine and an LLM/agent backend. The engine calls it with:
\`\`\`
uwf-<name> --thread <id> --role <role> --prompt <text>
\`\`\`
The adapter must produce frontmatter markdown output. The engine handles argument parsing, context building, output extraction, and CAS persistence you just implement the LLM interaction.
## Quick Start
\`\`\`typescript
import { createAgent } from "@uncaged/workflow-util-agent";
import type { AgentContext, AgentRunResult, AgentContinueFn, AgentRunFn } from "@uncaged/workflow-util-agent";
const run: AgentRunFn = async (ctx: AgentContext): Promise<AgentRunResult> => {
// 1. Build your prompt from ctx
// 2. Call your LLM backend
// 3. Return the result
return { output: rawMarkdown, detailHash, sessionId };
};
const continue_: AgentContinueFn = async (sessionId, message, store) => {
// Resume an existing session with a correction message
return { output: correctedMarkdown, detailHash, sessionId };
};
const main = createAgent({ name: "my-agent", run, continue: continue_ });
main();
\`\`\`
## The \`createAgent\` Factory
\`createAgent(options)\` returns an async \`main()\` function that handles the full lifecycle:
1. Parses CLI args (\`--thread\`, \`--role\`, \`--prompt\`)
2. Loads \`.env\` from storage root
3. Builds \`AgentContext\` (thread history, workflow definition, role prompt)
4. Injects \`outputFormatInstruction\` from the role's frontmatter schema
5. Calls your \`run(ctx)\` function
6. Extracts frontmatter from your output via \`tryFrontmatterFastPath()\`
7. If extraction fails, calls your \`continue(sessionId, correctionMessage, store)\` up to 2 times
8. Persists the validated output as a CAS step node
9. Prints the step hash to stdout
You only implement \`run\` and \`continue\`.
## AgentOptions
\`\`\`typescript
type AgentOptions = {
name: string; // Adapter name (used in step records as "uwf-<name>")
run: AgentRunFn; // Execute a role from scratch
continue: AgentContinueFn; // Resume a session for frontmatter correction
};
\`\`\`
## AgentContext
The \`ctx\` object passed to your \`run\` function:
| Field | Type | Description |
|-------|------|-------------|
| \`threadId\` | \`string\` | Thread ULID |
| \`role\` | \`string\` | Role name being executed |
| \`edgePrompt\` | \`string\` | Moderator's task instruction for this step |
| \`workflow\` | \`WorkflowPayload\` | Full workflow definition (roles, graph) |
| \`start\` | \`StartNodePayload\` | Thread start data (workflow hash, user prompt) |
| \`steps\` | \`StepContext[]\` | Previous steps with expanded outputs |
| \`store\` | \`Store\` | CAS store for reading/writing data |
| \`outputFormatInstruction\` | \`string\` | Frontmatter format instruction (inject into system prompt) |
| \`isFirstVisit\` | \`boolean\` | True if this role hasn't run before in this thread |
## AgentRunResult
Your \`run\` and \`continue\` functions must return:
\`\`\`typescript
type AgentRunResult = {
output: string; // Raw markdown with frontmatter (must start with ---)
detailHash: string; // CAS hash of session detail (turn history, metadata)
sessionId: string; // Session ID for potential continue() calls
};
\`\`\`
## Building the Prompt
Use helpers from \`@uncaged/workflow-util-agent\`:
| Helper | Purpose |
|--------|---------|
| \`buildRolePrompt(roleDef)\` | Assemble Goal/Capabilities/Prepare/Procedure/Output sections |
| \`buildContinuationPrompt(steps, role, edgePrompt)\` | For re-entry: steps since last visit + edge prompt |
| \`ctx.outputFormatInstruction\` | Pre-built frontmatter format block (inject into system prompt) |
Typical system prompt structure:
\`\`\`
[outputFormatInstruction]
[rolePrompt from buildRolePrompt()]
[workflow metadata]
\`\`\`
## Storing Session Detail
Store your turn history as a CAS merkle DAG for debugging and replay:
\`\`\`typescript
// Store each turn as a CAS text node
const turnHash = await store.put(textSchema, { content: turnData });
// Build a detail node referencing all turns
const detailHash = await store.put(detailSchema, { turns: turnHashes });
\`\`\`
The \`detailHash\` is preserved from the first \`run()\` call — retry \`continue()\` calls don't overwrite it.
## Registration
Register your adapter in \`~/.uncaged/workflow/config.yaml\`:
\`\`\`yaml
agents:
my-agent:
command: uwf-my-agent
args: []
\`\`\`
Use it:
\`\`\`bash
uwf thread exec <thread-id> --agent my-agent
\`\`\`
Or set as default:
\`\`\`yaml
defaultAgent: my-agent
\`\`\`
## Existing Adapters
| Adapter | Package | Backend |
|---------|---------|---------|
| \`uwf-hermes\` | \`@uncaged/workflow-agent-hermes\` | Hermes ACP (chat sessions) |
| \`uwf-builtin\` | \`@uncaged/workflow-agent-builtin\` | Direct OpenAI API (tools + loop) |
| \`uwf-claude-code\` | \`@uncaged/workflow-agent-claude-code\` | Claude Code CLI |
Study these for patterns on prompt building, session management, and detail storage.
## Checklist
1. Implement \`run(ctx)\` — build prompt, call LLM, return output + detailHash + sessionId
2. Implement \`continue(sessionId, message, store)\` — resume session for frontmatter correction
3. Store session detail as CAS nodes (for debugging)
4. Ensure output starts with \`---\` frontmatter block
5. Add a \`bin\` entry in \`package.json\` for the CLI command
6. Register in config.yaml and test with \`uwf thread exec --agent <name>\`
`;
}
@@ -0,0 +1,60 @@
export function generateArchitectureReference(): string {
return `# Workflow Engine — Architecture Reference
## Key Concepts
### CAS (Content-Addressed Storage)
Every artifact in the workflow engine is stored as a CAS node an immutable, content-addressed record identified by its XXH64 hash (13-char Crockford Base32). CAS provides deduplication, integrity verification, and an append-only audit trail.
Stored artifacts include:
- **Workflow definitions** the YAML-parsed payload
- **Step nodes** each moderatoragentextract cycle
- **Detail nodes** per-step metadata and turn history
- **Turn records** individual agent interactions within a step
### Thread
A Thread is a single execution of a Workflow, identified by a ULID (26-char Crockford Base32: 10 timestamp + 16 random). Thread state is an immutable CAS chain each step points to its predecessor via a \`prev\` hash, forming a linked list.
Active threads are indexed in \`threads.yaml\`; completed threads move to \`history.jsonl\`.
A thread progresses by running \`uwf thread exec\`, which performs one moderator→agent→extract cycle per step.
### Workflow
A Workflow is a YAML definition (\`WorkflowPayload\`) stored as a CAS node. It defines:
- **Roles** named actors with system prompts and output schemas
- **Graph** status-based routing edges between roles
- **Conditions** edge predicates evaluated by the moderator
Workflow names follow verb-first kebab-case: \`solve-issue\`, \`review-code\`.
### Step
A Step is one moderatoragentextract cycle, stored as a CAS node (\`StepNodePayload\`). Each step contains:
- **output** the agent's extracted frontmatter output
- **detail** a CAS reference to turn-level records
- **prev** CAS hash of the previous step (forming the chain)
- **role** which role produced this step
### Turn
A Turn is an agent-internal interaction within a single Step. Turns are stored per-turn in the detail node, capturing the raw agent I/O before extraction.
## Data Flow
\`\`\`
uwf thread exec <thread-id>
Moderator evaluates graph edges based on current status
Selects next role (or $END)
Agent CLI is spawned with context
Agent produces frontmatter markdown
Extract pipeline parses output into structured data
New CAS step node is appended to the thread chain
\`\`\`
## Storage Layout
All data lives under \`~/.uncaged/workflow/\`:
- \`cas/\` — content-addressed store (XXH64-keyed)
- \`threads.yaml\` — active thread index
- \`history.jsonl\` — completed thread archive
- \`registry.yaml\` — workflow name → CAS hash mapping
`;
}
@@ -0,0 +1,183 @@
export function generateAuthorReference(): string {
return `# Author Reference
Guide for designing and writing workflow YAML definitions.
## Workflow Structure
\`\`\`yaml
name: solve-issue # verb-first kebab-case
description: "..." # human-readable summary
roles: # named actors
planner:
description: "..." # short purpose
goal: "..." # system-level goal for the agent
capabilities: [...] # skill keywords the agent should load
procedure: | # step-by-step instructions
1. Do this
2. Do that
output: "..." # what the agent should produce
frontmatter: # JSON Schema for structured output
oneOf:
- properties:
$status: { const: "ready" }
plan: { type: string }
required: [$status, plan]
- properties:
$status: { const: "failed" }
error: { type: string }
required: [$status, error]
graph: # status-based routing
$START:
_: { role: planner, prompt: "Analyze the issue." }
planner:
ready: { role: developer, prompt: "Implement {{{plan}}}." }
failed: { role: $END, prompt: "Failed: {{{error}}}" }
\`\`\`
## Role Definition
| Field | Purpose |
|-------|---------|
| \`description\` | Short description for humans and moderator context |
| \`goal\` | Injected as the agent's system-level objective |
| \`capabilities\` | Keyword tags — agent loads matching skills before starting |
| \`procedure\` | Step-by-step instructions the agent follows |
| \`output\` | Describes what to produce and which \`$status\` values to use |
| \`frontmatter\` | JSON Schema defining the structured output fields |
### Role Design Principles
- **Single responsibility** each role does one thing well
- **Minimal context** don't overload a role with too many steps; split if needed
- **Clear status values** each status should map to a distinct graph edge
- **Explicit output** tell the agent exactly what \`$status\` values are valid
## Frontmatter Schema
The \`frontmatter\` field is a standard JSON Schema. It defines the structured fields the agent must output in YAML frontmatter.
### \`$status\` Field
\`$status\` is the only standard field. Its value determines which graph edge the moderator follows. Use \`const\` to constrain each variant:
\`\`\`yaml
frontmatter:
oneOf:
- properties:
$status: { const: "done" }
result: { type: string }
required: [$status, result]
- properties:
$status: { const: "failed" }
error: { type: string }
required: [$status, error]
\`\`\`
### Custom Fields
Add any fields you need for data passing between roles. These are available in edge prompts via Mustache templates.
### Flat Schema (Single Status)
When a role has only one outcome:
\`\`\`yaml
frontmatter:
properties:
$status: { const: "done" }
summary: { type: string }
required: [$status, summary]
\`\`\`
## Graph Routing
The graph maps each role's \`$status\` values to the next role:
\`\`\`
graph[role][$status] { role: nextRole, prompt: edgePrompt }
\`\`\`
### Special Nodes
| Node | Purpose |
|------|---------|
| \`$START\` | Entry point — status key is always \`_\` (unconditional) |
| \`$END\` | Terminal — thread completes and is archived |
### Edge Prompts
Use triple-brace Mustache (\`{{{field}}}\`) to pass data from the previous step's output:
\`\`\`yaml
graph:
planner:
ready: { role: developer, prompt: "Implement plan {{{plan}}} in {{{repoPath}}}." }
\`\`\`
The fields referenced must exist in the source role's frontmatter schema.
### Loops and Branching
Roles can route back to previous roles (loops) or to different roles based on status (branching):
\`\`\`yaml
graph:
reviewer:
approved: { role: tester, prompt: "Run tests." }
rejected: { role: developer, prompt: "Fix: {{{comments}}}" } # loop back
\`\`\`
### Fail Routing
Route failures to a cleanup role or \`$END\`:
\`\`\`yaml
graph:
developer:
done: { role: reviewer, prompt: "Review changes." }
failed: { role: cleanup, prompt: "Clean up: {{{error}}}" }
\`\`\`
## Self-Testing
### Step-by-Step Verification
\`\`\`bash
# Start a thread directly from YAML file (no registration needed)
uwf thread start my-workflow.yaml -p "Test prompt"
# Or register first, then start by name
uwf workflow add my-workflow.yaml
uwf thread start my-workflow -p "Test prompt"
# Execute one step at a time to verify routing
uwf thread exec <thread-id>
# Inspect step output
uwf step list <thread-id>
uwf step show <step-hash>
# Check the CAS data
uwf cas get <output-hash>
\`\`\`
### Validation Checklist
1. Every \`$status\` value in a role's frontmatter has a matching edge in the graph
2. Every field referenced in edge prompts (\`{{{field}}}\`) exists in the source role's schema
3. Every role referenced in the graph exists in \`roles\`
4. \`$START\` has exactly one edge with key \`_\`
5. At least one path leads to \`$END\`
6. No orphan roles (defined but never routed to)
## Common Pitfalls
- **Missing graph edge** if a role can produce \`$status: failed\` but the graph has no \`failed\` edge, the moderator will error
- **Mustache field mismatch** referencing \`{{{branch}}}\` in an edge prompt but the source schema has \`branchName\` instead
- **Overly complex roles** a role with 20 steps should be split; each role should be completable in one agent turn
- **No fail path** always handle failure; route to cleanup or \`$END\`
`;
}
@@ -0,0 +1,140 @@
export function generateDeveloperReference(): string {
return `# Developer Reference
Guide for contributing to the workflow engine codebase.
## Monorepo Structure
\`\`\`
packages/
workflow-protocol/ # Shared types (WorkflowPayload, StepNodePayload, etc.)
workflow-util/ # Base32, ULID, logger, frontmatter parsing, skill references
workflow-util-agent/ # createAgent factory, context builder, extract pipeline
workflow-agent-hermes/ # uwf-hermes CLI (spawns Hermes chat sessions)
workflow-agent-builtin/ # uwf-builtin CLI (direct LLM calls via OpenAI API)
cli-workflow/ # uwf CLI (moderator, thread/step/cas/config commands)
\`\`\`
Dependency layers (each only imports from packages above it):
\`\`\`
protocol util util-agent agent-hermes / agent-builtin / cli-workflow
\`\`\`
External CAS: \`@uncaged/json-cas\` (store API, hashing, schema validation) + \`@uncaged/json-cas-fs\` (filesystem backend).
## Coding Conventions
### Functional-first
| Rule | Description |
|------|-------------|
| \`type\` over \`interface\` | All type definitions use \`type\` |
| \`function\` over \`class\` | Pure functions + closures, no class |
| No \`this\` | Functions must not depend on \`this\` context |
| No inheritance | No \`extends\`, \`implements\`, \`abstract\` |
| No optional properties | Use \`T \\| null\` instead of \`?:\` |
| Immutability first | Use \`Readonly<T>\`, \`as const\`, avoid mutation |
Classes allowed only when required by third-party libraries or for Error subclasses.
### Error Handling
- \`Result<T, E>\` type for expected failures (\`ok\`/\`err\` constructors from \`@uncaged/workflow-util\`)
- \`throw\` only for unrecoverable bugs
- No try-catch for flow control
### Async
Always \`async/await\`, never \`.then()\` chains.
### Logging
\`console.*\` is banned (Biome \`noConsole\` rule). Use the structured logger:
\`\`\`typescript
import { createLogger } from "@uncaged/workflow-util";
const log = createLogger();
log("4KNMR2PX", "Loading workflow..."); // 8-char Crockford Base32 tag
\`\`\`
Each call site gets a unique hand-written tag. \`grep "4KNMR2PX"\` in logs → instant code location.
CLI package (\`@uncaged/cli-workflow\`) may use \`console.log\` for user-facing output with a biome-ignore comment.
### No Dynamic Import
No \`await import()\` in production code. Always static top-level \`import\`. Test files are exempt.
### Naming
- Workflow names: verb-first kebab-case (\`solve-issue\`, \`review-code\`)
- IDs: Crockford Base32 CAS hash (XXH64, 13-char), Thread ID (ULID, 26-char)
## Development Workflow
\`\`\`bash
bun install # install all workspace deps
bun run build # tsc --build (all packages)
bun run check # tsc + biome check + lint-log-tags
bun run format # biome format --write
bun test # run all tests
\`\`\`
Before committing: \`bun run check\` + \`bun test\` must both pass.
### Testing
- \`cli-workflow\`: vitest
- Other packages: \`bun test\`
- Test files live in \`__tests__/\` directories
### Publishing
Fixed-mode versioning all \`@uncaged/*\` packages share the same version number.
\`\`\`bash
bun changeset # describe the change
bun version # bump versions + changelogs
bun release # build + test + publish to npmjs
\`\`\`
## Key Modules
### Moderator (\`cli-workflow/src/moderator/\`)
Status-based graph evaluator. Reads \`graph[lastRole][output.$status]\` to determine the next role. Zero LLM cost.
### Extract Pipeline (\`workflow-util-agent/src/\`)
1. Agent produces frontmatter markdown
2. \`parseFrontmatterMarkdown()\` extracts YAML frontmatter
3. \`tryFrontmatterFastPath()\` validates against role's output schema
4. If fast path fails, retries up to 2 times via agent continue
5. Validated output stored as CAS node
### createAgent Factory (\`workflow-util-agent/src/run.ts\`)
Shared entry point for all agent CLIs. Handles:
- Argument parsing (\`--thread\`, \`--role\`, \`--prompt\`)
- Context building (thread history, workflow definition)
- Output extraction and CAS persistence
- Frontmatter retry loop
### CAS Integration
All data is CAS-addressed via \`@uncaged/json-cas\`:
- \`store.put(schemaHash, data)\` → content hash
- \`store.get(hash)\` → node
- \`validate(store, node)\` → schema check
- Schemas registered at workflow add time
## Commit Convention
\`\`\`
<type>(<scope>): <description>
type: feat | fix | refactor | docs | chore | test
scope: workflow | cli | moderator | util-agent | hermes | util | protocol
\`\`\`
`;
}
+8
View File
@@ -1,5 +1,10 @@
export { generateActorReference } from "./actor-reference.js";
export { generateAdapterReference } from "./adapter-reference.js";
export { generateArchitectureReference } from "./architecture-reference.js";
export { generateAuthorReference } from "./author-reference.js";
export { encodeUint64AsCrockford } from "./base32.js";
export { generateCliReference } from "./cli-reference.js";
export { generateDeveloperReference } from "./developer-reference.js";
export { env } from "./env.js";
export type {
AgentFrontmatter,
@@ -13,6 +18,7 @@ export {
validateFrontmatter,
} from "./frontmatter-markdown/index.js";
export { createLogger } from "./logger.js";
export { generateModeratorReference } from "./moderator-reference.js";
export type {
CreateProcessLoggerOptions,
ProcessLogFn,
@@ -25,3 +31,5 @@ export { err, ok } from "./result.js";
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
export type { LogFn, Result } from "./types.js";
export { extractUlidTimestamp, generateUlid } from "./ulid.js";
export { generateUserReference } from "./user-reference.js";
export { generateYamlReference } from "./yaml-reference.js";
@@ -0,0 +1,56 @@
export function generateModeratorReference(): string {
return `# Moderator Reference
## Overview
The moderator is the workflow engine's routing component. It evaluates the directed graph defined in the workflow YAML to determine the next role (or \`$END\`) after each step — with zero LLM cost.
## Status-Based Routing
The moderator uses **status-based routing**: it inspects the previous step's extracted output (specifically the \`$status\` field) and looks up the corresponding edge in the graph.
### Graph Structure
The graph is a nested map: \`Record<Role | "$START", Record<Status, Target>>\`. Each role maps its possible \`$status\` values to a target with a \`role\` and \`prompt\`:
\`\`\`yaml
graph:
$START:
_: { role: planner, prompt: "Analyze the issue." }
planner:
ready: { role: developer, prompt: "Implement the plan (CAS hash: {{{plan}}})." }
insufficient_info: { role: $END, prompt: "Not enough info." }
developer:
done: { role: reviewer, prompt: "Review branch {{{branch}}} at {{{worktree}}}." }
failed: { role: $END, prompt: "Developer failed: {{{reason}}}." }
reviewer:
approved: { role: tester, prompt: "Run tests on {{{branch}}} at {{{worktree}}}." }
rejected: { role: developer, prompt: "Fix issues: {{{comments}}}." }
\`\`\`
### Routing Algorithm
1. Look up \`graph[lastRole]\` to get the status map for the current role
2. Look up \`statusMap[lastOutput.$status]\` to get the target
3. If target role is \`$END\`, mark thread as completed
4. Otherwise, render the edge prompt (Mustache templates with \`{{{field}}}\` from output) and spawn the next agent
### Edge Prompts and Mustache Templates
Edge prompts use triple-brace Mustache syntax (\`{{{field}}}\`) to interpolate values from the previous step's output into the next agent's task prompt. This passes structured data (branch names, file paths, CAS hashes) between roles without manual wiring.
## Special Nodes
- \`$START\` — entry point; uses status key \`_\` (unconditional) since there is no previous output
- \`$END\` — terminal node; thread completes when reached and is moved to history
## Integration with Steps
Each \`uwf thread exec\` cycle:
1. Moderator reads the thread's head step output
2. Looks up \`graph[lastRole][output.$status]\` to pick the next role
3. If next is \`$END\`, marks thread as completed
4. Otherwise, renders the edge prompt and spawns the agent for the selected role
5. Extract pipeline parses agent output new step node append to CAS chain
`;
}
@@ -0,0 +1,125 @@
export function generateUserReference(): string {
return `# User Reference
Guide for using the uwf CLI to manage workflows and threads.
## Quick Start
\`\`\`bash
# 1. Configure provider and model
uwf setup
# 2. Register a workflow
uwf workflow add my-workflow.yaml
# 3. Start a thread (creates but does not execute)
uwf thread start my-workflow -p "Build a login page"
# 4. Execute the thread (runs moderator agent extract cycles)
uwf thread exec <thread-id> # one step
uwf thread exec <thread-id> -c 10 # up to 10 steps
uwf thread exec <thread-id> -c 10 --background # run in background
\`\`\`
## Concepts
- **Workflow** YAML definition with roles and a routing graph; stored as a CAS node
- **Thread** A running instance of a workflow; a chain of step nodes in CAS
- **Step** One moderator agent extract cycle; contains the role's structured output
- **CAS** Content-addressable store; every artifact is hashed (XXH64, Crockford Base32)
## Setup
\`\`\`
uwf setup # interactive wizard
uwf setup --provider <name> --base-url <url> \\
--api-key <key> --model <name> # non-interactive
[--agent <name>] # optional default agent
\`\`\`
Config is stored at \`~/.uncaged/workflow/config.yaml\`. Override storage root with \`UNCAGED_WORKFLOW_STORAGE_ROOT\`.
## Workflow Commands
\`\`\`
uwf workflow add <file> # register from YAML file
uwf workflow show <id> # show by name or CAS hash
uwf workflow list # list all registered workflows
\`\`\`
You can also pass a file path directly to \`uwf thread start\` without registering first.
## Thread Lifecycle
\`\`\`
uwf thread start <workflow> -p <prompt> # create thread
uwf thread exec <thread-id> # execute one step
[--agent <cmd>] # override agent
[-c, --count <n>] # run n steps
[--background] # run in background
uwf thread show <thread-id> # show head pointer
uwf thread list # list all threads
[--status <filter>] # idle, running, completed, cancelled, active (comma-separated)
[--after <thread-id>] # pagination: after this thread
[--before <thread-id>] # pagination: before this thread
[--skip <n>] # skip first n results
[--take <n>] # limit results
uwf thread read <thread-id> # render context as markdown
[--quota <chars>] # max output chars (default 4000)
[--before <step-hash>] # pagination
[--start] # include start step
uwf thread stop <thread-id> # stop background execution
uwf thread cancel <thread-id> # cancel and archive thread
\`\`\`
### Typical Lifecycle
\`\`\`
start exec (repeat) thread reaches $END auto-completed
or: cancel to abort
\`\`\`
## Step Commands
\`\`\`
uwf step list <thread-id> # list all steps
uwf step show <step-hash> # show step details
uwf step fork <step-hash> # fork thread from a step (branch)
\`\`\`
Forking creates a new thread that shares history up to the fork point useful for retrying from a known-good state.
## CAS Commands
\`\`\`
uwf cas get <hash> # read a node (type + payload)
[--timestamp] # include timestamp
uwf cas put <type-hash> <data> # store typed JSON, 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 schemas
uwf cas schema get <hash> # show schema definition
\`\`\`
## Log Commands
\`\`\`
uwf log list # list log files
uwf log show # show log entries
[--thread <id>] # filter by thread
[--process <pid>] # filter by process
[--date <YYYY-MM-DD>] # filter by date
uwf log clean --before <date> # delete old logs
\`\`\`
## Global Options
\`\`\`
uwf --format <json|yaml> # output format (default: json)
uwf -V, --version # print version
\`\`\`
`;
}
@@ -0,0 +1,82 @@
export function generateYamlReference(): string {
return `# Workflow YAML Schema Reference
## Top-Level Structure
A workflow YAML file defines the complete workflow specification:
\`\`\`yaml
name: solve-issue # verb-first kebab-case identifier
description: "..." # human-readable description
roles: # named actors in the workflow
planner:
description: "Analyzes issue and outputs a plan"
goal: "You are a planning agent."
capabilities:
- issue-analysis
- planning
procedure: |
1. Read the issue
2. Produce a test spec
output: "Output the plan summary. Set $status to ready or insufficient_info."
frontmatter: # JSON Schema for structured output (drives routing)
oneOf:
- properties:
$status: { const: ready }
plan: { type: string }
required: [$status, plan]
- properties:
$status: { const: insufficient_info }
required: [$status]
graph: # status-based routing (nested map)
$START:
_: { role: planner, prompt: "Analyze the issue." }
planner:
ready: { role: developer, prompt: "Implement plan {{{plan}}}." }
insufficient_info: { role: $END, prompt: "Not enough info." }
\`\`\`
## roles
Each role defines an actor in the workflow:
| Field | Type | Description |
|-------|------|-------------|
| \`description\` | string | Short description of the role's purpose |
| \`goal\` | string | System-level goal statement for the agent |
| \`capabilities\` | string[] | Tags describing what the role can do |
| \`procedure\` | string | Step-by-step instructions for the agent |
| \`output\` | string | Description of expected output format |
| \`frontmatter\` | JSON Schema | Defines the structured output the agent must produce |
### frontmatter
The \`frontmatter\` field is a standard JSON Schema object. The extract pipeline validates agent output against it. Key conventions:
- \`$status\` field drives routing decisions in the graph
- Use \`const\` or \`enum\` to constrain status values
- Use \`oneOf\` to define multiple valid output shapes (one per status)
- All \`required\` fields must appear in the agent's frontmatter output
## graph
The graph is a nested map defining status-based routing:
\`\`\`
Record<Role | "$START", Record<Status, { role: string, prompt: string }>>
\`\`\`
| Level | Key | Value |
|-------|-----|-------|
| Outer | Role name or \`$START\` | Status map for that role |
| Inner | \`$status\` value (or \`_\` for unconditional) | Target: \`{ role, prompt }\` |
### Special Nodes
- \`$START\` — entry point; uses status key \`_\` (unconditional, no previous output)
- \`$END\` — terminal node; thread completes when reached
### Edge Prompts
Prompts use triple-brace Mustache templates (\`{{{field}}}\`) to interpolate values from the previous step's output. Example: \`"Implement plan {{{plan}}} in repo {{{repoPath}}}."\`
`;
}
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Check development environment prerequisites for uncaged/workflow.
# Non-interactive — prints actionable fix instructions on failure.
# Exit 0 = all good, exit 1 = missing dependencies.
set -euo pipefail
errors=0
check() {
local name="$1" check_cmd="$2" fix_msg="$3"
if eval "$check_cmd" >/dev/null 2>&1; then
echo "$name"
else
echo "$name"
echo " Fix: $fix_msg"
errors=$((errors + 1))
fi
}
check_version() {
local name="$1" cmd="$2" fix_msg="$3"
local version
if version=$(eval "$cmd" 2>/dev/null | head -1); then
echo "$name$version"
else
echo "$name"
echo " Fix: $fix_msg"
errors=$((errors + 1))
fi
}
echo "=== Runtime ==="
check_version "bun" "bun --version" \
"curl -fsSL https://bun.sh/install | bash"
check_version "node" "node --version" \
"Install Node.js 20+: https://nodejs.org/"
check_version "python3" "python3 --version" \
"Install Python 3.11+: https://www.python.org/ or use uv: curl -LsSf https://astral.sh/uv/install.sh | sh && uv python install 3.11"
echo ""
echo "=== Tools ==="
check_version "hermes" "hermes --version" \
"See https://github.com/hermes-ai/hermes-agent for installation. Typical: pip install hermes-agent (or uv pip install -e . for dev)"
check_version "claude" "claude --version" \
"npm install -g @anthropic-ai/claude-code"
echo ""
echo "=== Workflow ==="
# Check repo location
REPO_DIR="${WORKFLOW_REPO:-$(cd "$(dirname "$0")/.." && pwd)}"
check "repo at ~/repos/workflow or WORKFLOW_REPO set" \
"[ -f '$REPO_DIR/packages/cli-workflow/src/cli.ts' ]" \
"Clone the repo: git clone https://git.shazhou.work/uncaged/workflow ~/repos/workflow"
# Check bun install
check "node_modules installed" \
"[ -d '$REPO_DIR/node_modules' ]" \
"cd $REPO_DIR && bun install"
# Check build
check "packages built (dist/)" \
"[ -f '$REPO_DIR/packages/cli-workflow/dist/cli.js' ]" \
"cd $REPO_DIR && bun run build"
# Check uwf is runnable
check_version "uwf" "bun $REPO_DIR/packages/cli-workflow/src/cli.ts --version" \
"cd $REPO_DIR && bun install && bun run build"
# Check uwf symlink
check "uwf in PATH" \
"command -v uwf" \
"sudo ln -sf $REPO_DIR/packages/cli-workflow/dist/cli.js /usr/bin/uwf && sudo chmod +x /usr/bin/uwf"
# Check uwf-hermes
check "uwf-hermes in PATH" \
"command -v uwf-hermes" \
"bun link in packages/workflow-agent-hermes, or: echo '#!/usr/bin/env bun' > ~/.local/bin/uwf-hermes && echo 'import \"$REPO_DIR/packages/workflow-agent-hermes/src/cli.ts\"' >> ~/.local/bin/uwf-hermes && chmod +x ~/.local/bin/uwf-hermes"
# Check uwf-claude-code
check "uwf-claude-code in PATH" \
"command -v uwf-claude-code" \
"Create wrapper: echo '#!/bin/bash\nexec bun run $REPO_DIR/packages/workflow-agent-claude-code/src/cli.ts \"\$@\"' > ~/.local/bin/uwf-claude-code && chmod +x ~/.local/bin/uwf-claude-code"
echo ""
echo "=== Config ==="
# Check workflow config exists
CONFIG_DIR="${UNCAGED_WORKFLOW_STORAGE_ROOT:-$HOME/.uncaged/workflow}"
check "config.yaml exists" \
"[ -f '$CONFIG_DIR/config.yaml' ]" \
"Run: uwf setup"
# Check config has apiKey (not apiKeyEnv)
if [ -f "$CONFIG_DIR/config.yaml" ]; then
check "config uses apiKey (not legacy apiKeyEnv)" \
"grep -q 'apiKey:' '$CONFIG_DIR/config.yaml' && ! grep -q 'apiKeyEnv:' '$CONFIG_DIR/config.yaml'" \
"Run: uwf setup (re-configure to write apiKey directly)"
fi
echo ""
echo "=== Docker (optional, for E2E tests) ==="
check_version "docker" "docker --version" \
"sudo apt install -y docker.io && sudo usermod -aG docker \$USER"
check "docker daemon running" \
"docker info" \
"sudo systemctl start docker"
echo ""
if [ "$errors" -gt 0 ]; then
echo "⚠️ $errors issue(s) found. Fix them and re-run this script."
exit 1
else
echo "🎉 All checks passed!"
exit 0
fi
+377
View File
@@ -0,0 +1,377 @@
#!/usr/bin/env bash
# E2E walkthrough for uncaged/workflow.
# Runs inside Docker with isolated UNCAGED_WORKFLOW_STORAGE_ROOT.
# Exercises: setup → workflow add → thread start/exec → cancel/fork → read/inspect.
#
# Usage:
# sudo -E scripts/e2e-walkthrough.sh [--agent <agent>] [--provider <provider>] [--model <model>] [--api-key <key>]
#
# Requires: Docker running, $HOME mount approach (see scripts/check-dev-env.sh).
# Produces: JSON report on stdout, logs in $E2E_DIR.
#
# IMPORTANT: Must run with `sudo -E` to preserve $HOME (Docker needs root).
#
# Known Issues (WIP):
# 1. `echo '$OUT' | jq` breaks when $OUT contains single quotes (e.g. workflow show
# output with YAML). Fix: use heredoc or pipe variable directly.
# 2. Config may still have old `apiKeyEnv` field — thread exec will fail with
# "no API key". Fix: re-run `uwf setup` or manually set `apiKey` in config.
# 3. Bootstrap installs jq via apt-get which adds ~30s startup time.
# Consider baking a custom image or using node's JSON.parse instead.
# 4. `bun install` in container may modify host's lockfile/node_modules.
# Consider `--frozen-lockfile` or read-only mount for non-essential paths.
set -euo pipefail
# --- Args ---
AGENT="uwf-builtin"
PROVIDER=""
MODEL=""
API_KEY=""
KEEP_CONTAINER=false
while [[ $# -gt 0 ]]; do
case "$1" in
--agent) AGENT="$2"; shift 2 ;;
--provider) PROVIDER="$2"; shift 2 ;;
--model) MODEL="$2"; shift 2 ;;
--api-key) API_KEY="$2"; shift 2 ;;
--keep) KEEP_CONTAINER=true; shift ;;
*) echo "Unknown arg: $1" >&2; exit 1 ;;
esac
done
# --- Resolve paths ---
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
E2E_DIR=$(mktemp -d /tmp/uwf-e2e-XXXXXX)
CONTAINER_NAME="uwf-e2e-$(date +%s)"
echo "=== uwf E2E walkthrough ===" >&2
echo "Agent: $AGENT" >&2
echo "Provider: ${PROVIDER:-"(from config)"}" >&2
echo "Model: ${MODEL:-"(from config)"}" >&2
echo "E2E dir: $E2E_DIR" >&2
echo "Container: $CONTAINER_NAME" >&2
echo "" >&2
# --- Cleanup ---
cleanup() {
if [ "$KEEP_CONTAINER" = false ]; then
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
fi
}
trap cleanup EXIT
# --- Build inner script ---
# This runs INSIDE the container with an isolated storage root.
cat > "$E2E_DIR/run.sh" << 'INNER_SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
# Isolated storage — never touches host's ~/.uncaged/workflow
export UNCAGED_WORKFLOW_STORAGE_ROOT="/tmp/uwf-e2e-storage"
mkdir -p "$UNCAGED_WORKFLOW_STORAGE_ROOT"
REPO_DIR="$1"
AGENT="$2"
PROVIDER="$3"
MODEL="$4"
API_KEY="$5"
# Ensure tools are in PATH (derive HOME from REPO_DIR to avoid container HOME issues)
REAL_HOME="${6:-$HOME}"
export HOME="$REAL_HOME"
export PATH="$REAL_HOME/.bun/bin:$REAL_HOME/.hermes/hermes-agent/venv/bin:$REAL_HOME/.local/share/npm/bin:$PATH"
# Resolve uwf
UWF="bun $REPO_DIR/packages/cli-workflow/src/cli.ts"
PASS=0
FAIL=0
RESULTS=()
run_test() {
local name="$1"
shift
local output exit_code
echo "--- TEST: $name ---" >&2
output=$("$@" 2>&1) && exit_code=0 || exit_code=$?
if [ $exit_code -eq 0 ]; then
PASS=$((PASS + 1))
RESULTS+=("{\"name\":\"$name\",\"status\":\"pass\"}")
echo " ✅ PASS" >&2
else
FAIL=$((FAIL + 1))
# Escape output for JSON
local escaped
escaped=$(echo "$output" | head -5 | tr '\n' ' ' | sed 's/"/\\"/g' | cut -c1-200)
RESULTS+=("{\"name\":\"$name\",\"status\":\"fail\",\"error\":\"$escaped\"}")
echo " ❌ FAIL: $output" >&2
fi
echo "$output"
}
assert_contains() {
local haystack="$1" needle="$2"
if echo "$haystack" | grep -q "$needle"; then
return 0
else
echo "Expected to contain: $needle" >&2
echo "Got: $haystack" >&2
return 1
fi
}
assert_json_field() {
local json="$1" field="$2"
if echo "$json" | jq -e ".$field" >/dev/null 2>&1; then
return 0
else
echo "Missing JSON field: $field" >&2
return 1
fi
}
# ============================================================
# Phase 1: Environment check
# ============================================================
echo "" >&2
echo "=== Phase 1: Environment ===" >&2
run_test "uwf --version" bash -c "$UWF --version"
# ============================================================
# Phase 2: Setup (non-interactive)
# ============================================================
echo "" >&2
echo "=== Phase 2: Setup ===" >&2
if [ -n "$PROVIDER" ] && [ -n "$MODEL" ] && [ -n "$API_KEY" ]; then
SETUP_CMD="$UWF setup --provider $PROVIDER --base-url https://api.openai.com/v1 --api-key $API_KEY --model $MODEL"
if [ -n "$AGENT" ]; then
SETUP_CMD="$SETUP_CMD --agent $AGENT"
fi
run_test "uwf setup (non-interactive)" bash -c "$SETUP_CMD"
else
# Copy host config if available
if [ -f "$HOME/.uncaged/workflow/config.yaml" ]; then
cp "$HOME/.uncaged/workflow/config.yaml" "$UNCAGED_WORKFLOW_STORAGE_ROOT/config.yaml"
echo " Copied host config.yaml" >&2
fi
fi
# Test config commands
OUT=$(run_test "uwf config list" bash -c "$UWF config list")
run_test "config list is valid JSON" bash -c "echo '$OUT' | jq . >/dev/null"
# ============================================================
# Phase 3: Workflow registration
# ============================================================
echo "" >&2
echo "=== Phase 3: Workflow registration ===" >&2
# Use the example workflow
EXAMPLE_WF="$REPO_DIR/examples/solve-issue.yaml"
if [ ! -f "$EXAMPLE_WF" ]; then
echo "No example workflow found, creating minimal test workflow" >&2
EXAMPLE_WF="/tmp/test-workflow.yaml"
cat > "$EXAMPLE_WF" << 'WF'
name: test-e2e
roles:
worker:
goal: "Respond to the prompt with a brief answer."
outputSchema:
type: object
required: ["$status", "answer"]
properties:
$status:
type: string
enum: ["done"]
answer:
type: string
graph:
- from: $START
to: worker
- from: worker
condition:
$status: done
to: $END
WF
fi
OUT=$(run_test "uwf workflow add" bash -c "$UWF workflow add $EXAMPLE_WF")
run_test "workflow add returns hash" bash -c "echo '$OUT' | jq -e '.hash'"
OUT=$(run_test "uwf workflow list" bash -c "$UWF workflow list")
run_test "workflow list is non-empty" bash -c "echo '$OUT' | jq -e 'length > 0'"
# Get workflow name
WF_NAME=$(echo "$OUT" | jq -r '.[0].name // empty')
run_test "workflow has a name" bash -c "[ -n '$WF_NAME' ]"
OUT=$(run_test "uwf workflow show" bash -c "$UWF workflow show $WF_NAME")
run_test "workflow show returns roles" bash -c "echo '$OUT' | jq -e '.payload.roles'"
# ============================================================
# Phase 4: Thread lifecycle
# ============================================================
echo "" >&2
echo "=== Phase 4: Thread lifecycle ===" >&2
# Start a thread
OUT=$(run_test "uwf thread start" bash -c "$UWF thread start $WF_NAME -p 'E2E test: what is 2+2?'")
THREAD_ID=$(echo "$OUT" | jq -r '.thread // empty')
run_test "thread start returns thread ID" bash -c "[ -n '$THREAD_ID' ]"
# List threads
OUT=$(run_test "uwf thread list" bash -c "$UWF thread list")
run_test "thread appears in list" bash -c "echo '$OUT' | jq -e '.[] | select(.thread==\"$THREAD_ID\")'"
# Show thread
OUT=$(run_test "uwf thread show" bash -c "$UWF thread show $THREAD_ID")
run_test "thread show returns head" bash -c "echo '$OUT' | jq -e '.head'"
# Execute one step
EXEC_ARGS=""
if [ -n "$AGENT" ]; then
EXEC_ARGS="--agent $AGENT"
fi
OUT=$(run_test "uwf thread exec (1 step)" bash -c "$UWF thread exec $THREAD_ID $EXEC_ARGS")
run_test "thread exec returns step info" bash -c "echo '$OUT' | jq -e '.head'"
# ============================================================
# Phase 5: Read & Inspect
# ============================================================
echo "" >&2
echo "=== Phase 5: Read & Inspect ===" >&2
# Step list
OUT=$(run_test "uwf step list" bash -c "$UWF step list $THREAD_ID")
STEP_COUNT=$(echo "$OUT" | jq '.steps | length')
run_test "step list has steps" bash -c "[ $STEP_COUNT -gt 1 ]"
# Get last step hash
LAST_STEP=$(echo "$OUT" | jq -r '.steps[-1].hash // empty')
run_test "last step has hash" bash -c "[ -n '$LAST_STEP' ]"
# Step show
if [ -n "$LAST_STEP" ]; then
OUT=$(run_test "uwf step show" bash -c "$UWF step show $LAST_STEP")
run_test "step show returns role" bash -c "echo '$OUT' | jq -e '.role'"
fi
# Thread read
OUT=$(run_test "uwf thread read" bash -c "$UWF thread read $THREAD_ID")
run_test "thread read produces output" bash -c "[ -n '$OUT' ]"
# CAS operations
if [ -n "$LAST_STEP" ]; then
OUT=$(run_test "uwf cas get" bash -c "$UWF cas get $LAST_STEP")
run_test "cas get returns type" bash -c "echo '$OUT' | jq -e '.type'"
OUT=$(run_test "uwf cas has" bash -c "$UWF cas has $LAST_STEP")
OUT=$(run_test "uwf cas refs" bash -c "$UWF cas refs $LAST_STEP")
OUT=$(run_test "uwf cas walk" bash -c "$UWF cas walk $LAST_STEP")
run_test "cas walk returns nodes" bash -c "echo '$OUT' | jq -e 'length > 0'"
fi
# ============================================================
# Phase 6: Cancel & Fork
# ============================================================
echo "" >&2
echo "=== Phase 6: Cancel & Fork ===" >&2
# Start a second thread for cancel test
OUT=$(run_test "thread start (for cancel)" bash -c "$UWF thread start $WF_NAME -p 'E2E cancel test'")
CANCEL_THREAD=$(echo "$OUT" | jq -r '.thread // empty')
if [ -n "$CANCEL_THREAD" ]; then
OUT=$(run_test "uwf thread cancel" bash -c "$UWF thread cancel $CANCEL_THREAD")
run_test "cancelled thread status" bash -c "$UWF thread list --status completed | jq -e '.[] | select(.thread==\"$CANCEL_THREAD\")'"
fi
# Fork from the first thread's last step
if [ -n "$LAST_STEP" ]; then
OUT=$(run_test "uwf step fork" bash -c "$UWF step fork $LAST_STEP")
FORK_THREAD=$(echo "$OUT" | jq -r '.thread // empty')
run_test "fork creates new thread" bash -c "[ -n '$FORK_THREAD' ] && [ '$FORK_THREAD' != '$THREAD_ID' ]"
fi
# ============================================================
# Phase 7: Log inspection
# ============================================================
echo "" >&2
echo "=== Phase 7: Logs ===" >&2
OUT=$(run_test "uwf log list" bash -c "$UWF log list")
OUT=$(run_test "uwf log show" bash -c "$UWF log show --thread $THREAD_ID 2>&1 || true")
# ============================================================
# Phase 8: Config operations
# ============================================================
echo "" >&2
echo "=== Phase 8: Config get/set ===" >&2
OUT=$(run_test "uwf config get defaultAgent" bash -c "$UWF config get defaultAgent")
OUT=$(run_test "uwf config set (test key)" bash -c "$UWF config set models.test.name test-model")
OUT=$(run_test "uwf config get (verify set)" bash -c "$UWF config get models.test.name")
run_test "config set value persisted" bash -c "echo '$OUT' | grep -q 'test-model'"
# ============================================================
# Report
# ============================================================
echo "" >&2
echo "=== Results ===" >&2
echo "Pass: $PASS Fail: $FAIL" >&2
# JSON report
echo "{"
echo " \"pass\": $PASS,"
echo " \"fail\": $FAIL,"
echo " \"agent\": \"$AGENT\","
echo " \"tests\": [$(IFS=,; echo "${RESULTS[*]}")]"
echo "}"
[ $FAIL -eq 0 ]
INNER_SCRIPT
chmod +x "$E2E_DIR/run.sh"
# --- Run in Docker ---
echo "Starting Docker container..." >&2
# --- Build bootstrap script (runs first inside container) ---
cat > "$E2E_DIR/bootstrap.sh" << BOOTSTRAP
#!/usr/bin/env bash
set -uo pipefail
echo "Installing jq..." >&2
apt-get update -qq >&2 && apt-get install -y -qq jq >&2
echo "jq installed" >&2
# All tools come from host via mount
export HOME='$HOME'
export PATH="$HOME/.bun/bin:$HOME/.hermes/hermes-agent/venv/bin:$HOME/.local/share/npm/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
# Ensure bun modules are resolved for this environment
cd '$REPO_DIR'
echo "Running bun install..." >&2
which bun >&2
bun install 2>&1 | tail -3 >&2
echo "bun install done" >&2
# Run E2E (pass HOME explicitly as 6th arg)
bash /e2e/run.sh '$REPO_DIR' '$AGENT' '$PROVIDER' '$MODEL' '$API_KEY' '$HOME'
BOOTSTRAP
chmod +x "$E2E_DIR/bootstrap.sh"
docker run --rm \
--name "$CONTAINER_NAME" \
-v "$HOME:$HOME" \
-v "$E2E_DIR:/e2e" \
-e HOME="$HOME" \
-w "$REPO_DIR" \
node:22-bookworm \
bash /e2e/bootstrap.sh