discoclaw 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.context/README.md +42 -0
- package/.context/architecture.md +58 -0
- package/.context/bot-setup.md +24 -0
- package/.context/dev.md +230 -0
- package/.context/discord.md +144 -0
- package/.context/memory.md +257 -0
- package/.context/ops.md +59 -0
- package/.context/pa-safety.md +47 -0
- package/.context/pa.md +118 -0
- package/.context/project.md +43 -0
- package/.context/runtime.md +253 -0
- package/.context/tasks.md +71 -0
- package/.context/tools.md +75 -0
- package/.env.example +88 -0
- package/.env.example.full +378 -0
- package/LICENSE +21 -0
- package/README.md +220 -0
- package/dist/beads/auto-tag.js +2 -0
- package/dist/beads/auto-tag.test.js +62 -0
- package/dist/beads/bd-cli.js +9 -0
- package/dist/beads/bd-cli.test.js +495 -0
- package/dist/beads/bead-hooks-cli.js +149 -0
- package/dist/beads/bead-sync-cli.js +5 -0
- package/dist/beads/bead-sync-cli.test.js +72 -0
- package/dist/beads/bead-sync-coordinator.js +4 -0
- package/dist/beads/bead-sync-coordinator.test.js +239 -0
- package/dist/beads/bead-sync-watcher.js +2 -0
- package/dist/beads/bead-sync-watcher.test.js +96 -0
- package/dist/beads/bead-sync.js +7 -0
- package/dist/beads/bead-sync.test.js +876 -0
- package/dist/beads/bead-thread-cache.js +8 -0
- package/dist/beads/bead-thread-cache.test.js +91 -0
- package/dist/beads/discord-sync.js +18 -0
- package/dist/beads/discord-sync.test.js +782 -0
- package/dist/beads/find-bead-by-thread.test.js +36 -0
- package/dist/beads/forum-guard.js +2 -0
- package/dist/beads/forum-guard.test.js +204 -0
- package/dist/beads/initialize.js +3 -0
- package/dist/beads/initialize.test.js +304 -0
- package/dist/beads/types.js +10 -0
- package/dist/cli/daemon-installer.js +225 -0
- package/dist/cli/daemon-installer.test.js +289 -0
- package/dist/cli/index.js +42 -0
- package/dist/cli/init-wizard.js +374 -0
- package/dist/cli/init-wizard.test.js +191 -0
- package/dist/config.js +385 -0
- package/dist/config.test.js +589 -0
- package/dist/cron/auto-tag.js +100 -0
- package/dist/cron/auto-tag.test.js +91 -0
- package/dist/cron/cadence.js +74 -0
- package/dist/cron/cadence.test.js +53 -0
- package/dist/cron/cron-sync-coordinator.js +66 -0
- package/dist/cron/cron-sync-coordinator.test.js +118 -0
- package/dist/cron/cron-sync.js +165 -0
- package/dist/cron/cron-sync.test.js +228 -0
- package/dist/cron/cron-tag-map-watcher.js +128 -0
- package/dist/cron/cron-tag-map-watcher.test.js +155 -0
- package/dist/cron/default-timezone.js +23 -0
- package/dist/cron/default-timezone.test.js +30 -0
- package/dist/cron/discord-sync.js +205 -0
- package/dist/cron/discord-sync.test.js +353 -0
- package/dist/cron/executor.js +303 -0
- package/dist/cron/executor.test.js +614 -0
- package/dist/cron/forum-sync.js +347 -0
- package/dist/cron/forum-sync.test.js +539 -0
- package/dist/cron/job-lock.js +164 -0
- package/dist/cron/job-lock.test.js +178 -0
- package/dist/cron/parser.js +68 -0
- package/dist/cron/parser.test.js +115 -0
- package/dist/cron/run-control.js +24 -0
- package/dist/cron/run-control.test.js +27 -0
- package/dist/cron/run-stats.js +265 -0
- package/dist/cron/run-stats.test.js +160 -0
- package/dist/cron/scheduler.js +97 -0
- package/dist/cron/scheduler.test.js +112 -0
- package/dist/cron/tag-map.js +47 -0
- package/dist/cron/tag-map.test.js +64 -0
- package/dist/cron/types.js +1 -0
- package/dist/discoclaw-plan-format.test.js +137 -0
- package/dist/discoclaw-recipe-format.test.js +137 -0
- package/dist/discord/abort-registry.js +70 -0
- package/dist/discord/action-categories.js +36 -0
- package/dist/discord/action-types.js +1 -0
- package/dist/discord/action-utils.js +58 -0
- package/dist/discord/action-utils.test.js +58 -0
- package/dist/discord/actions-beads.js +1 -0
- package/dist/discord/actions-beads.test.js +372 -0
- package/dist/discord/actions-bot-profile.js +107 -0
- package/dist/discord/actions-bot-profile.test.js +138 -0
- package/dist/discord/actions-channels.js +427 -0
- package/dist/discord/actions-channels.test.js +697 -0
- package/dist/discord/actions-config.js +173 -0
- package/dist/discord/actions-config.test.js +322 -0
- package/dist/discord/actions-crons.js +586 -0
- package/dist/discord/actions-crons.test.js +499 -0
- package/dist/discord/actions-defer.js +60 -0
- package/dist/discord/actions-defer.test.js +134 -0
- package/dist/discord/actions-forge.js +134 -0
- package/dist/discord/actions-forge.test.js +206 -0
- package/dist/discord/actions-guild.js +301 -0
- package/dist/discord/actions-guild.test.js +386 -0
- package/dist/discord/actions-memory.js +106 -0
- package/dist/discord/actions-memory.test.js +248 -0
- package/dist/discord/actions-messaging.js +401 -0
- package/dist/discord/actions-messaging.test.js +738 -0
- package/dist/discord/actions-moderation.js +65 -0
- package/dist/discord/actions-moderation.test.js +88 -0
- package/dist/discord/actions-plan.js +445 -0
- package/dist/discord/actions-plan.test.js +610 -0
- package/dist/discord/actions-poll.js +38 -0
- package/dist/discord/actions-poll.test.js +93 -0
- package/dist/discord/actions-tasks.js +3 -0
- package/dist/discord/actions-tasks.test.js +418 -0
- package/dist/discord/actions.js +600 -0
- package/dist/discord/actions.test.js +522 -0
- package/dist/discord/allowed-mentions.js +3 -0
- package/dist/discord/allowed-mentions.test.js +17 -0
- package/dist/discord/allowlist.js +29 -0
- package/dist/discord/allowlist.test.js +24 -0
- package/dist/discord/audit-handler.js +191 -0
- package/dist/discord/audit-handler.test.js +361 -0
- package/dist/discord/bot.js +141 -0
- package/dist/discord/channel-context.js +181 -0
- package/dist/discord/defer-scheduler.js +45 -0
- package/dist/discord/destructive-confirmation.js +128 -0
- package/dist/discord/destructive-confirmation.test.js +49 -0
- package/dist/discord/discord-plan-auto-implement.test.js +18 -0
- package/dist/discord/durable-memory.js +145 -0
- package/dist/discord/durable-memory.test.js +281 -0
- package/dist/discord/durable-write-queue.js +4 -0
- package/dist/discord/file-download.js +308 -0
- package/dist/discord/file-download.test.js +303 -0
- package/dist/discord/forge-audit-verdict.js +140 -0
- package/dist/discord/forge-auto-implement.js +80 -0
- package/dist/discord/forge-auto-implement.test.js +110 -0
- package/dist/discord/forge-commands.js +698 -0
- package/dist/discord/forge-commands.test.js +1606 -0
- package/dist/discord/forge-plan-registry.js +68 -0
- package/dist/discord/forge-plan-registry.test.js +127 -0
- package/dist/discord/forum-count-sync.js +130 -0
- package/dist/discord/forum-count-sync.test.js +200 -0
- package/dist/discord/health-command.js +98 -0
- package/dist/discord/health-command.test.js +195 -0
- package/dist/discord/help-command.js +22 -0
- package/dist/discord/help-command.test.js +49 -0
- package/dist/discord/image-download.js +201 -0
- package/dist/discord/image-download.test.js +499 -0
- package/dist/discord/inflight-replies.js +228 -0
- package/dist/discord/inflight-replies.test.js +295 -0
- package/dist/discord/json-extract.js +110 -0
- package/dist/discord/keyed-queue.js +22 -0
- package/dist/discord/memory-commands.js +85 -0
- package/dist/discord/memory-commands.test.js +159 -0
- package/dist/discord/memory-timing.integration.test.js +159 -0
- package/dist/discord/message-coordinator.js +2347 -0
- package/dist/discord/message-coordinator.onboarding.test.js +183 -0
- package/dist/discord/message-coordinator.plan-run.test.js +264 -0
- package/dist/discord/message-history.js +53 -0
- package/dist/discord/message-history.test.js +95 -0
- package/dist/discord/models-command.js +59 -0
- package/dist/discord/models-command.test.js +150 -0
- package/dist/discord/nickname.test.js +76 -0
- package/dist/discord/onboarding-completion.js +55 -0
- package/dist/discord/onboarding-completion.test.js +176 -0
- package/dist/discord/output-common.js +178 -0
- package/dist/discord/output-common.test.js +198 -0
- package/dist/discord/output-utils.js +156 -0
- package/dist/discord/parse-identity-name.test.js +129 -0
- package/dist/discord/plan-commands.js +612 -0
- package/dist/discord/plan-commands.test.js +1622 -0
- package/dist/discord/plan-manager.js +1491 -0
- package/dist/discord/plan-manager.test.js +2380 -0
- package/dist/discord/plan-parser.js +110 -0
- package/dist/discord/plan-parser.test.js +63 -0
- package/dist/discord/plan-run-phase-start.js +20 -0
- package/dist/discord/plan-run-phase-start.test.js +29 -0
- package/dist/discord/platform-message.js +45 -0
- package/dist/discord/platform-message.test.js +110 -0
- package/dist/discord/prompt-common.js +240 -0
- package/dist/discord/prompt-common.test.js +423 -0
- package/dist/discord/reaction-handler.js +691 -0
- package/dist/discord/reaction-handler.test.js +1574 -0
- package/dist/discord/reaction-prompts.js +118 -0
- package/dist/discord/reaction-prompts.test.js +253 -0
- package/dist/discord/reply-reference.js +66 -0
- package/dist/discord/reply-reference.test.js +125 -0
- package/dist/discord/restart-command.js +143 -0
- package/dist/discord/restart-command.test.js +196 -0
- package/dist/discord/runtime-utils.js +43 -0
- package/dist/discord/runtime-utils.test.js +112 -0
- package/dist/discord/session-key.js +7 -0
- package/dist/discord/session-key.test.js +13 -0
- package/dist/discord/shortterm-memory.js +166 -0
- package/dist/discord/shortterm-memory.test.js +345 -0
- package/dist/discord/shutdown-context.js +122 -0
- package/dist/discord/shutdown-context.test.js +279 -0
- package/dist/discord/startup-profile.test.js +214 -0
- package/dist/discord/status-channel.js +190 -0
- package/dist/discord/status-channel.test.js +282 -0
- package/dist/discord/status-command.js +206 -0
- package/dist/discord/status-command.test.js +341 -0
- package/dist/discord/streaming-progress.js +107 -0
- package/dist/discord/streaming-progress.test.js +93 -0
- package/dist/discord/summarizer.js +89 -0
- package/dist/discord/summarizer.test.js +245 -0
- package/dist/discord/system-bootstrap.js +396 -0
- package/dist/discord/system-bootstrap.test.js +724 -0
- package/dist/discord/thread-context.js +169 -0
- package/dist/discord/thread-context.test.js +386 -0
- package/dist/discord/tool-aware-queue.js +116 -0
- package/dist/discord/tool-aware-queue.test.js +180 -0
- package/dist/discord/update-command.js +127 -0
- package/dist/discord/update-command.test.js +275 -0
- package/dist/discord/user-errors.js +40 -0
- package/dist/discord/user-errors.test.js +31 -0
- package/dist/discord/user-turn-to-durable.js +111 -0
- package/dist/discord/user-turn-to-durable.test.js +273 -0
- package/dist/discord-followup.test.js +677 -0
- package/dist/discord.channel-context.test.js +95 -0
- package/dist/discord.fail-closed.test.js +199 -0
- package/dist/discord.health-command.integration.test.js +140 -0
- package/dist/discord.js +190 -0
- package/dist/discord.prompt-context.test.js +1431 -0
- package/dist/discord.render.test.js +621 -0
- package/dist/discord.status-wiring.test.js +187 -0
- package/dist/engine/claudeCli.js +137 -0
- package/dist/engine/types.js +1 -0
- package/dist/group-queue.js +25 -0
- package/dist/health/credential-check.js +175 -0
- package/dist/health/credential-check.test.js +401 -0
- package/dist/health/startup-healing.js +139 -0
- package/dist/health/startup-healing.test.js +298 -0
- package/dist/identity.js +36 -0
- package/dist/index.js +1378 -0
- package/dist/logging/logger-like.js +1 -0
- package/dist/observability/memory-sampler.js +51 -0
- package/dist/observability/memory-sampler.test.js +93 -0
- package/dist/observability/metrics.js +88 -0
- package/dist/observability/metrics.test.js +42 -0
- package/dist/onboarding/onboarding-flow.js +246 -0
- package/dist/onboarding/onboarding-flow.test.js +238 -0
- package/dist/onboarding/onboarding-writer.js +102 -0
- package/dist/onboarding/onboarding-writer.test.js +143 -0
- package/dist/pidlock.js +187 -0
- package/dist/pidlock.test.js +128 -0
- package/dist/pipeline/engine.js +206 -0
- package/dist/pipeline/engine.test.js +771 -0
- package/dist/root-policy.js +21 -0
- package/dist/root-policy.test.js +55 -0
- package/dist/runtime/claude-code-cli.js +35 -0
- package/dist/runtime/claude-code-cli.test.js +1199 -0
- package/dist/runtime/cli-adapter.js +584 -0
- package/dist/runtime/cli-output-parsers.js +108 -0
- package/dist/runtime/cli-shared.js +96 -0
- package/dist/runtime/cli-shared.test.js +104 -0
- package/dist/runtime/cli-strategy.js +6 -0
- package/dist/runtime/codex-cli.js +16 -0
- package/dist/runtime/codex-cli.test.js +862 -0
- package/dist/runtime/concurrency-limit.js +80 -0
- package/dist/runtime/concurrency-limit.test.js +137 -0
- package/dist/runtime/gemini-cli.js +16 -0
- package/dist/runtime/gemini-cli.test.js +413 -0
- package/dist/runtime/long-running-process.js +415 -0
- package/dist/runtime/long-running-process.test.js +318 -0
- package/dist/runtime/model-smoke-helpers.js +160 -0
- package/dist/runtime/model-smoke.test.js +194 -0
- package/dist/runtime/model-tiers.js +33 -0
- package/dist/runtime/model-tiers.test.js +65 -0
- package/dist/runtime/openai-auth.js +151 -0
- package/dist/runtime/openai-auth.test.js +361 -0
- package/dist/runtime/openai-compat.js +178 -0
- package/dist/runtime/openai-compat.test.js +449 -0
- package/dist/runtime/process-pool.js +93 -0
- package/dist/runtime/process-pool.test.js +148 -0
- package/dist/runtime/registry.js +15 -0
- package/dist/runtime/registry.test.js +47 -0
- package/dist/runtime/session-scanner.js +186 -0
- package/dist/runtime/session-scanner.test.js +257 -0
- package/dist/runtime/strategies/claude-strategy.js +193 -0
- package/dist/runtime/strategies/codex-strategy.js +161 -0
- package/dist/runtime/strategies/gemini-strategy.js +64 -0
- package/dist/runtime/strategies/template-strategy.js +85 -0
- package/dist/runtime/tool-capabilities.js +27 -0
- package/dist/runtime/tool-capabilities.test.js +24 -0
- package/dist/runtime/tool-labels.js +48 -0
- package/dist/runtime/types.js +2 -0
- package/dist/sessionManager.js +47 -0
- package/dist/sessions.js +18 -0
- package/dist/tasks/architecture-contract.js +33 -0
- package/dist/tasks/architecture-contract.test.js +90 -0
- package/dist/tasks/auto-tag.js +50 -0
- package/dist/tasks/auto-tag.test.js +64 -0
- package/dist/tasks/bd-cli.js +164 -0
- package/dist/tasks/bd-cli.test.js +359 -0
- package/dist/tasks/bead-sync.js +1 -0
- package/dist/tasks/context-summary.js +27 -0
- package/dist/tasks/discord-sync.js +3 -0
- package/dist/tasks/discord-sync.test.js +685 -0
- package/dist/tasks/discord-types.js +4 -0
- package/dist/tasks/find-task-by-thread.test.js +36 -0
- package/dist/tasks/forum-guard.js +81 -0
- package/dist/tasks/forum-guard.test.js +192 -0
- package/dist/tasks/initialize.js +77 -0
- package/dist/tasks/initialize.test.js +263 -0
- package/dist/tasks/logger-types.js +1 -0
- package/dist/tasks/metrics-types.js +3 -0
- package/dist/tasks/migrate.js +33 -0
- package/dist/tasks/migrate.test.js +156 -0
- package/dist/tasks/path-defaults.js +67 -0
- package/dist/tasks/path-defaults.test.js +73 -0
- package/dist/tasks/runtime-types.js +1 -0
- package/dist/tasks/service.js +33 -0
- package/dist/tasks/service.test.js +51 -0
- package/dist/tasks/store.js +238 -0
- package/dist/tasks/store.test.js +417 -0
- package/dist/tasks/sync-context.js +1 -0
- package/dist/tasks/sync-contract.js +24 -0
- package/dist/tasks/sync-contract.test.js +25 -0
- package/dist/tasks/sync-coordinator-metrics.js +41 -0
- package/dist/tasks/sync-coordinator-retries.js +71 -0
- package/dist/tasks/sync-coordinator.js +96 -0
- package/dist/tasks/sync-coordinator.test.js +501 -0
- package/dist/tasks/sync-types.js +1 -0
- package/dist/tasks/sync-watcher.js +27 -0
- package/dist/tasks/sync-watcher.test.js +92 -0
- package/dist/tasks/tag-map.js +36 -0
- package/dist/tasks/tag-map.test.js +54 -0
- package/dist/tasks/task-action-contract.js +16 -0
- package/dist/tasks/task-action-contract.test.js +16 -0
- package/dist/tasks/task-action-executor.js +18 -0
- package/dist/tasks/task-action-executor.test.js +420 -0
- package/dist/tasks/task-action-mutation-helpers.js +17 -0
- package/dist/tasks/task-action-mutations.js +151 -0
- package/dist/tasks/task-action-prompt.js +62 -0
- package/dist/tasks/task-action-read-ops.js +73 -0
- package/dist/tasks/task-action-runner-types.js +1 -0
- package/dist/tasks/task-action-thread-sync.js +82 -0
- package/dist/tasks/task-actions.js +3 -0
- package/dist/tasks/task-cli.js +227 -0
- package/dist/tasks/task-context.js +1 -0
- package/dist/tasks/task-lifecycle.js +46 -0
- package/dist/tasks/task-lifecycle.test.js +35 -0
- package/dist/tasks/task-sync-apply-plan.js +95 -0
- package/dist/tasks/task-sync-apply-types.js +12 -0
- package/dist/tasks/task-sync-apply.js +319 -0
- package/dist/tasks/task-sync-cli.js +89 -0
- package/dist/tasks/task-sync-cli.test.js +70 -0
- package/dist/tasks/task-sync-engine.js +88 -0
- package/dist/tasks/task-sync-engine.test.js +934 -0
- package/dist/tasks/task-sync-phase-apply.js +171 -0
- package/dist/tasks/task-sync-pipeline.js +2 -0
- package/dist/tasks/task-sync-pipeline.test.js +265 -0
- package/dist/tasks/task-sync-reconcile-plan.js +182 -0
- package/dist/tasks/task-sync-reconcile.js +144 -0
- package/dist/tasks/task-sync.js +56 -0
- package/dist/tasks/task-sync.test.js +86 -0
- package/dist/tasks/thread-cache.js +42 -0
- package/dist/tasks/thread-cache.test.js +89 -0
- package/dist/tasks/thread-contracts.test.js +711 -0
- package/dist/tasks/thread-forum-ops.js +68 -0
- package/dist/tasks/thread-helpers.js +86 -0
- package/dist/tasks/thread-helpers.test.js +33 -0
- package/dist/tasks/thread-lifecycle-ops.js +144 -0
- package/dist/tasks/thread-ops-shared.js +21 -0
- package/dist/tasks/thread-ops.js +2 -0
- package/dist/tasks/types.js +20 -0
- package/dist/tasks/types.test.js +60 -0
- package/dist/test-setup.js +11 -0
- package/dist/test-setup.test.js +42 -0
- package/dist/transport/types.js +1 -0
- package/dist/validate.js +41 -0
- package/dist/validate.test.js +94 -0
- package/dist/version.js +15 -0
- package/dist/version.test.js +31 -0
- package/dist/webhook/server.js +199 -0
- package/dist/webhook/server.test.js +460 -0
- package/dist/workspace-bootstrap.js +135 -0
- package/dist/workspace-bootstrap.test.js +514 -0
- package/dist/workspace-permissions.js +134 -0
- package/dist/workspace-permissions.test.js +181 -0
- package/package.json +74 -0
- package/scripts/cron/cron-tag-map.json +9 -0
- package/scripts/tasks/tag-map.json +10 -0
- package/systemd/discoclaw.service +19 -0
- package/templates/recipes/integration.discoclaw-recipe.md +171 -0
- package/templates/workspace/AGENTS.md +217 -0
- package/templates/workspace/BOOTSTRAP.md +1 -0
- package/templates/workspace/HEARTBEAT.md +10 -0
- package/templates/workspace/IDENTITY.md +16 -0
- package/templates/workspace/MEMORY.md +24 -0
- package/templates/workspace/SOUL.md +52 -0
- package/templates/workspace/TOOLS.md +304 -0
- package/templates/workspace/USER.md +37 -0
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { runPipeline } from './engine.js';
|
|
3
|
+
vi.mock('execa', () => ({
|
|
4
|
+
execa: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
import { execa } from 'execa';
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Helpers
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
function makeRuntime(events) {
|
|
11
|
+
return {
|
|
12
|
+
id: 'other',
|
|
13
|
+
capabilities: new Set(['streaming_text']),
|
|
14
|
+
async *invoke(params) {
|
|
15
|
+
const evts = typeof events === 'function' ? events(params.prompt) : events;
|
|
16
|
+
for (const evt of evts) {
|
|
17
|
+
yield evt;
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function step(prompt, overrides) {
|
|
23
|
+
return { kind: 'prompt', prompt, ...overrides };
|
|
24
|
+
}
|
|
25
|
+
function baseParams(overrides) {
|
|
26
|
+
return {
|
|
27
|
+
steps: [],
|
|
28
|
+
runtime: makeRuntime([{ type: 'done' }]),
|
|
29
|
+
cwd: '/tmp',
|
|
30
|
+
model: 'test-model',
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function makeExecaResult(overrides = {}) {
|
|
35
|
+
return {
|
|
36
|
+
stdout: '',
|
|
37
|
+
stderr: '',
|
|
38
|
+
exitCode: 0,
|
|
39
|
+
failed: false,
|
|
40
|
+
timedOut: false,
|
|
41
|
+
isCanceled: false,
|
|
42
|
+
signal: undefined,
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Tests
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
describe('runPipeline', () => {
|
|
50
|
+
it('returns empty outputs for an empty steps array', async () => {
|
|
51
|
+
const result = await runPipeline(baseParams());
|
|
52
|
+
expect(result.outputs).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
it('runs a single-step pipeline and returns output', async () => {
|
|
55
|
+
const result = await runPipeline(baseParams({
|
|
56
|
+
steps: [step('Say hello')],
|
|
57
|
+
runtime: makeRuntime([{ type: 'text_final', text: 'Hello!' }, { type: 'done' }]),
|
|
58
|
+
}));
|
|
59
|
+
expect(result.outputs).toEqual(['Hello!']);
|
|
60
|
+
});
|
|
61
|
+
it('accumulates text_delta events when text_final is absent', async () => {
|
|
62
|
+
const result = await runPipeline(baseParams({
|
|
63
|
+
steps: [step('prompt')],
|
|
64
|
+
runtime: makeRuntime([
|
|
65
|
+
{ type: 'text_delta', text: 'Hello ' },
|
|
66
|
+
{ type: 'text_delta', text: 'world' },
|
|
67
|
+
{ type: 'done' },
|
|
68
|
+
]),
|
|
69
|
+
}));
|
|
70
|
+
expect(result.outputs[0]).toBe('Hello world');
|
|
71
|
+
});
|
|
72
|
+
it('prefers text_final over accumulated text_delta', async () => {
|
|
73
|
+
const result = await runPipeline(baseParams({
|
|
74
|
+
steps: [step('prompt')],
|
|
75
|
+
runtime: makeRuntime([
|
|
76
|
+
{ type: 'text_delta', text: 'partial' },
|
|
77
|
+
{ type: 'text_final', text: 'final text' },
|
|
78
|
+
{ type: 'done' },
|
|
79
|
+
]),
|
|
80
|
+
}));
|
|
81
|
+
expect(result.outputs[0]).toBe('final text');
|
|
82
|
+
});
|
|
83
|
+
it('interpolates {{prev.output}} in a static prompt string', async () => {
|
|
84
|
+
const capturedPrompts = [];
|
|
85
|
+
const runtime = {
|
|
86
|
+
id: 'other',
|
|
87
|
+
capabilities: new Set(['streaming_text']),
|
|
88
|
+
async *invoke(params) {
|
|
89
|
+
capturedPrompts.push(params.prompt);
|
|
90
|
+
yield { type: 'text_final', text: `out:${params.prompt}` };
|
|
91
|
+
yield { type: 'done' };
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
await runPipeline(baseParams({
|
|
95
|
+
steps: [step('first'), step('second prev="{{prev.output}}"')],
|
|
96
|
+
runtime,
|
|
97
|
+
}));
|
|
98
|
+
expect(capturedPrompts[1]).toBe('second prev="out:first"');
|
|
99
|
+
});
|
|
100
|
+
it('interpolates {{steps.<id>.output}} for named step references', async () => {
|
|
101
|
+
const capturedPrompts = [];
|
|
102
|
+
const runtime = {
|
|
103
|
+
id: 'other',
|
|
104
|
+
capabilities: new Set(['streaming_text']),
|
|
105
|
+
async *invoke(params) {
|
|
106
|
+
capturedPrompts.push(params.prompt);
|
|
107
|
+
yield { type: 'text_final', text: `r:${params.prompt}` };
|
|
108
|
+
yield { type: 'done' };
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
await runPipeline(baseParams({
|
|
112
|
+
steps: [
|
|
113
|
+
step('alpha', { id: 'step1' }),
|
|
114
|
+
step('beta'),
|
|
115
|
+
step('result={{steps.step1.output}}'),
|
|
116
|
+
],
|
|
117
|
+
runtime,
|
|
118
|
+
}));
|
|
119
|
+
expect(capturedPrompts[2]).toBe('result=r:alpha');
|
|
120
|
+
});
|
|
121
|
+
it('interpolates {{steps.<id>.output}} when step id contains hyphens', async () => {
|
|
122
|
+
const capturedPrompts = [];
|
|
123
|
+
const runtime = {
|
|
124
|
+
id: 'other',
|
|
125
|
+
capabilities: new Set(['streaming_text']),
|
|
126
|
+
async *invoke(params) {
|
|
127
|
+
capturedPrompts.push(params.prompt);
|
|
128
|
+
yield { type: 'text_final', text: `r:${params.prompt}` };
|
|
129
|
+
yield { type: 'done' };
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
await runPipeline(baseParams({
|
|
133
|
+
steps: [
|
|
134
|
+
step('artifact', { id: 'build-artifact' }),
|
|
135
|
+
step('deploy={{steps.build-artifact.output}}'),
|
|
136
|
+
],
|
|
137
|
+
runtime,
|
|
138
|
+
}));
|
|
139
|
+
expect(capturedPrompts[1]).toBe('deploy=r:artifact');
|
|
140
|
+
});
|
|
141
|
+
it('leaves unresolvable {{steps.nonexistent.output}} as literal text', async () => {
|
|
142
|
+
const capturedPrompts = [];
|
|
143
|
+
const runtime = {
|
|
144
|
+
id: 'other',
|
|
145
|
+
capabilities: new Set(['streaming_text']),
|
|
146
|
+
async *invoke(params) {
|
|
147
|
+
capturedPrompts.push(params.prompt);
|
|
148
|
+
yield { type: 'text_final', text: 'done' };
|
|
149
|
+
yield { type: 'done' };
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
await runPipeline(baseParams({
|
|
153
|
+
steps: [step('ref={{steps.nonexistent.output}}')],
|
|
154
|
+
runtime,
|
|
155
|
+
}));
|
|
156
|
+
expect(capturedPrompts[0]).toBe('ref={{steps.nonexistent.output}}');
|
|
157
|
+
});
|
|
158
|
+
it('feeds previous step output into next step via callback prompt', async () => {
|
|
159
|
+
const capturedPrompts = [];
|
|
160
|
+
const runtime = {
|
|
161
|
+
id: 'other',
|
|
162
|
+
capabilities: new Set(['streaming_text']),
|
|
163
|
+
async *invoke(params) {
|
|
164
|
+
capturedPrompts.push(params.prompt);
|
|
165
|
+
yield { type: 'text_final', text: `out:${params.prompt}` };
|
|
166
|
+
yield { type: 'done' };
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
await runPipeline(baseParams({
|
|
170
|
+
steps: [
|
|
171
|
+
step('first'),
|
|
172
|
+
step((ctx) => `second prev="${ctx.previousOutput}"`),
|
|
173
|
+
],
|
|
174
|
+
runtime,
|
|
175
|
+
}));
|
|
176
|
+
expect(capturedPrompts[1]).toBe('second prev="out:first"');
|
|
177
|
+
});
|
|
178
|
+
it('provides allOutputs from all preceding steps to callback prompts', async () => {
|
|
179
|
+
let capturedCtx;
|
|
180
|
+
const runtime = {
|
|
181
|
+
id: 'other',
|
|
182
|
+
capabilities: new Set(['streaming_text']),
|
|
183
|
+
async *invoke(params) {
|
|
184
|
+
yield { type: 'text_final', text: `r:${params.prompt}` };
|
|
185
|
+
yield { type: 'done' };
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
await runPipeline(baseParams({
|
|
189
|
+
steps: [
|
|
190
|
+
step('a'),
|
|
191
|
+
step('b'),
|
|
192
|
+
step((ctx) => {
|
|
193
|
+
capturedCtx = { ...ctx, allOutputs: [...ctx.allOutputs] };
|
|
194
|
+
return 'c';
|
|
195
|
+
}),
|
|
196
|
+
],
|
|
197
|
+
runtime,
|
|
198
|
+
}));
|
|
199
|
+
expect(capturedCtx?.stepIndex).toBe(2);
|
|
200
|
+
expect(capturedCtx?.previousOutput).toBe('r:b');
|
|
201
|
+
expect(capturedCtx?.allOutputs).toEqual(['r:a', 'r:b']);
|
|
202
|
+
});
|
|
203
|
+
it('uses step-level model override instead of pipeline model', async () => {
|
|
204
|
+
const usedModels = [];
|
|
205
|
+
const runtime = {
|
|
206
|
+
id: 'other',
|
|
207
|
+
capabilities: new Set(['streaming_text']),
|
|
208
|
+
async *invoke(params) {
|
|
209
|
+
usedModels.push(params.model);
|
|
210
|
+
yield { type: 'text_final', text: 'done' };
|
|
211
|
+
yield { type: 'done' };
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
await runPipeline(baseParams({
|
|
215
|
+
steps: [
|
|
216
|
+
step('first'),
|
|
217
|
+
step('second', { model: 'override-model' }),
|
|
218
|
+
],
|
|
219
|
+
runtime,
|
|
220
|
+
model: 'default-model',
|
|
221
|
+
}));
|
|
222
|
+
expect(usedModels[0]).toBe('default-model');
|
|
223
|
+
expect(usedModels[1]).toBe('override-model');
|
|
224
|
+
});
|
|
225
|
+
it('uses step-level runtime override instead of pipeline runtime', async () => {
|
|
226
|
+
const pipelineInvoked = [];
|
|
227
|
+
const stepInvoked = [];
|
|
228
|
+
const pipelineRuntime = {
|
|
229
|
+
id: 'other',
|
|
230
|
+
capabilities: new Set(['streaming_text']),
|
|
231
|
+
async *invoke(params) {
|
|
232
|
+
pipelineInvoked.push(params.prompt);
|
|
233
|
+
yield { type: 'text_final', text: 'from-pipeline' };
|
|
234
|
+
yield { type: 'done' };
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
const stepRuntime = {
|
|
238
|
+
id: 'other',
|
|
239
|
+
capabilities: new Set(['streaming_text']),
|
|
240
|
+
async *invoke(params) {
|
|
241
|
+
stepInvoked.push(params.prompt);
|
|
242
|
+
yield { type: 'text_final', text: 'from-step' };
|
|
243
|
+
yield { type: 'done' };
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
const result = await runPipeline(baseParams({
|
|
247
|
+
steps: [
|
|
248
|
+
step('first'),
|
|
249
|
+
step('second', { runtime: stepRuntime }),
|
|
250
|
+
],
|
|
251
|
+
runtime: pipelineRuntime,
|
|
252
|
+
}));
|
|
253
|
+
expect(pipelineInvoked).toEqual(['first']);
|
|
254
|
+
expect(stepInvoked).toEqual(['second']);
|
|
255
|
+
expect(result.outputs[1]).toBe('from-step');
|
|
256
|
+
});
|
|
257
|
+
it('throws with step index on runtime error event', async () => {
|
|
258
|
+
await expect(runPipeline(baseParams({
|
|
259
|
+
steps: [step('bad')],
|
|
260
|
+
runtime: makeRuntime([{ type: 'error', message: 'boom' }]),
|
|
261
|
+
}))).rejects.toThrow('Pipeline step 0 failed: boom');
|
|
262
|
+
});
|
|
263
|
+
it('includes the correct step index in the error message for later steps', async () => {
|
|
264
|
+
const runtime = {
|
|
265
|
+
id: 'other',
|
|
266
|
+
capabilities: new Set(['streaming_text']),
|
|
267
|
+
async *invoke(params) {
|
|
268
|
+
if (params.prompt === 'bad') {
|
|
269
|
+
yield { type: 'error', message: 'oops' };
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
yield { type: 'text_final', text: 'ok' };
|
|
273
|
+
yield { type: 'done' };
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
await expect(runPipeline(baseParams({
|
|
278
|
+
steps: [step('good'), step('bad')],
|
|
279
|
+
runtime,
|
|
280
|
+
}))).rejects.toThrow('Pipeline step 1 failed: oops');
|
|
281
|
+
});
|
|
282
|
+
it('onError skip: middle step fails and subsequent step still runs', async () => {
|
|
283
|
+
const runtime = {
|
|
284
|
+
id: 'other',
|
|
285
|
+
capabilities: new Set(['streaming_text']),
|
|
286
|
+
async *invoke(params) {
|
|
287
|
+
if (params.prompt === 'bad') {
|
|
288
|
+
yield { type: 'error', message: 'boom' };
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
yield { type: 'text_final', text: `ok:${params.prompt}` };
|
|
292
|
+
yield { type: 'done' };
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
const result = await runPipeline(baseParams({
|
|
297
|
+
steps: [
|
|
298
|
+
step('first'),
|
|
299
|
+
step('bad', { onError: 'skip' }),
|
|
300
|
+
step('third'),
|
|
301
|
+
],
|
|
302
|
+
runtime,
|
|
303
|
+
}));
|
|
304
|
+
expect(result.outputs).toEqual(['ok:first', '', 'ok:third']);
|
|
305
|
+
});
|
|
306
|
+
it('rejects duplicate step IDs before running any steps', async () => {
|
|
307
|
+
const invoked = [];
|
|
308
|
+
const runtime = {
|
|
309
|
+
id: 'other',
|
|
310
|
+
capabilities: new Set(['streaming_text']),
|
|
311
|
+
async *invoke(params) {
|
|
312
|
+
invoked.push(params.prompt);
|
|
313
|
+
yield { type: 'text_final', text: 'done' };
|
|
314
|
+
yield { type: 'done' };
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
await expect(runPipeline(baseParams({
|
|
318
|
+
steps: [
|
|
319
|
+
step('first', { id: 'dup' }),
|
|
320
|
+
step('second', { id: 'dup' }),
|
|
321
|
+
],
|
|
322
|
+
runtime,
|
|
323
|
+
}))).rejects.toThrow('Duplicate step ID: "dup"');
|
|
324
|
+
expect(invoked).toHaveLength(0);
|
|
325
|
+
});
|
|
326
|
+
it('calls onProgress once per step with the step id in the message', async () => {
|
|
327
|
+
const messages = [];
|
|
328
|
+
await runPipeline(baseParams({
|
|
329
|
+
steps: [
|
|
330
|
+
step('hello', { id: 'alpha' }),
|
|
331
|
+
step('world', { id: 'beta' }),
|
|
332
|
+
],
|
|
333
|
+
runtime: makeRuntime([{ type: 'text_final', text: 'done' }, { type: 'done' }]),
|
|
334
|
+
onProgress: (msg) => messages.push(msg),
|
|
335
|
+
}));
|
|
336
|
+
expect(messages).toHaveLength(2);
|
|
337
|
+
expect(messages[0]).toContain('alpha');
|
|
338
|
+
expect(messages[1]).toContain('beta');
|
|
339
|
+
});
|
|
340
|
+
it('onProgress is called for skipped steps as well', async () => {
|
|
341
|
+
const messages = [];
|
|
342
|
+
const runtime = {
|
|
343
|
+
id: 'other',
|
|
344
|
+
capabilities: new Set(['streaming_text']),
|
|
345
|
+
async *invoke(params) {
|
|
346
|
+
if (params.prompt === 'bad') {
|
|
347
|
+
yield { type: 'error', message: 'boom' };
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
yield { type: 'text_final', text: 'ok' };
|
|
351
|
+
yield { type: 'done' };
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
await runPipeline(baseParams({
|
|
356
|
+
steps: [
|
|
357
|
+
step('good', { id: 'step-ok' }),
|
|
358
|
+
step('bad', { id: 'step-err', onError: 'skip' }),
|
|
359
|
+
],
|
|
360
|
+
runtime,
|
|
361
|
+
onProgress: (msg) => messages.push(msg),
|
|
362
|
+
}));
|
|
363
|
+
expect(messages).toHaveLength(2);
|
|
364
|
+
expect(messages[0]).toContain('step-ok');
|
|
365
|
+
expect(messages[1]).toContain('step-err');
|
|
366
|
+
});
|
|
367
|
+
it('forwards step-level timeoutMs to runtime invoke params', async () => {
|
|
368
|
+
const capturedTimeouts = [];
|
|
369
|
+
const runtime = {
|
|
370
|
+
id: 'other',
|
|
371
|
+
capabilities: new Set(['streaming_text']),
|
|
372
|
+
async *invoke(params) {
|
|
373
|
+
capturedTimeouts.push(params.timeoutMs);
|
|
374
|
+
yield { type: 'text_final', text: 'done' };
|
|
375
|
+
yield { type: 'done' };
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
await runPipeline(baseParams({
|
|
379
|
+
steps: [
|
|
380
|
+
step('first'),
|
|
381
|
+
step('second', { timeoutMs: 5000 }),
|
|
382
|
+
],
|
|
383
|
+
runtime,
|
|
384
|
+
}));
|
|
385
|
+
expect(capturedTimeouts[0]).toBeUndefined();
|
|
386
|
+
expect(capturedTimeouts[1]).toBe(5000);
|
|
387
|
+
});
|
|
388
|
+
it('skips all steps when signal is already aborted', async () => {
|
|
389
|
+
const invoked = [];
|
|
390
|
+
const runtime = {
|
|
391
|
+
id: 'other',
|
|
392
|
+
capabilities: new Set(['streaming_text']),
|
|
393
|
+
async *invoke(params) {
|
|
394
|
+
invoked.push(params.prompt);
|
|
395
|
+
yield { type: 'text_final', text: 'done' };
|
|
396
|
+
yield { type: 'done' };
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
const controller = new AbortController();
|
|
400
|
+
controller.abort();
|
|
401
|
+
const result = await runPipeline(baseParams({
|
|
402
|
+
steps: [step('step-one'), step('step-two')],
|
|
403
|
+
runtime,
|
|
404
|
+
signal: controller.signal,
|
|
405
|
+
}));
|
|
406
|
+
expect(invoked).toHaveLength(0);
|
|
407
|
+
expect(result.outputs).toEqual([]);
|
|
408
|
+
});
|
|
409
|
+
it('stops between steps when signal is aborted after the first step', async () => {
|
|
410
|
+
const controller = new AbortController();
|
|
411
|
+
const invoked = [];
|
|
412
|
+
const runtime = {
|
|
413
|
+
id: 'other',
|
|
414
|
+
capabilities: new Set(['streaming_text']),
|
|
415
|
+
async *invoke(params) {
|
|
416
|
+
invoked.push(params.prompt);
|
|
417
|
+
if (params.prompt === 'first')
|
|
418
|
+
controller.abort();
|
|
419
|
+
yield { type: 'text_final', text: `out:${params.prompt}` };
|
|
420
|
+
yield { type: 'done' };
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
const result = await runPipeline(baseParams({
|
|
424
|
+
steps: [step('first'), step('second')],
|
|
425
|
+
runtime,
|
|
426
|
+
signal: controller.signal,
|
|
427
|
+
}));
|
|
428
|
+
// First step ran; second was skipped because signal was aborted between steps.
|
|
429
|
+
expect(invoked).toEqual(['first']);
|
|
430
|
+
expect(result.outputs).toEqual(['out:first']);
|
|
431
|
+
});
|
|
432
|
+
it('passes step-level tools and addDirs to runtime', async () => {
|
|
433
|
+
const capturedParams = [];
|
|
434
|
+
const runtime = {
|
|
435
|
+
id: 'other',
|
|
436
|
+
capabilities: new Set(['streaming_text']),
|
|
437
|
+
async *invoke(params) {
|
|
438
|
+
capturedParams.push({ tools: params.tools, addDirs: params.addDirs });
|
|
439
|
+
yield { type: 'text_final', text: 'done' };
|
|
440
|
+
yield { type: 'done' };
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
await runPipeline(baseParams({
|
|
444
|
+
steps: [step('go', { tools: ['Read', 'Glob'], addDirs: ['/workspace'] })],
|
|
445
|
+
runtime,
|
|
446
|
+
}));
|
|
447
|
+
expect(capturedParams[0].tools).toEqual(['Read', 'Glob']);
|
|
448
|
+
expect(capturedParams[0].addDirs).toEqual(['/workspace']);
|
|
449
|
+
});
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
// Shell step tests
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
describe('shell steps', () => {
|
|
454
|
+
beforeEach(() => {
|
|
455
|
+
vi.mocked(execa).mockReset();
|
|
456
|
+
});
|
|
457
|
+
function shellStep(command, overrides) {
|
|
458
|
+
return { kind: 'shell', command, ...overrides };
|
|
459
|
+
}
|
|
460
|
+
it('runs a shell command and captures trimmed stdout as output', async () => {
|
|
461
|
+
vi.mocked(execa).mockResolvedValue(makeExecaResult({ stdout: 'hello\n' }));
|
|
462
|
+
const result = await runPipeline(baseParams({ steps: [shellStep(['echo', 'hello'])] }));
|
|
463
|
+
expect(result.outputs).toEqual(['hello']);
|
|
464
|
+
});
|
|
465
|
+
it('passes command array and options to execa', async () => {
|
|
466
|
+
vi.mocked(execa).mockResolvedValue(makeExecaResult({ stdout: 'out' }));
|
|
467
|
+
await runPipeline(baseParams({
|
|
468
|
+
steps: [shellStep(['ls', '-la'], { timeoutMs: 3000 })],
|
|
469
|
+
cwd: '/project',
|
|
470
|
+
}));
|
|
471
|
+
expect(vi.mocked(execa)).toHaveBeenCalledWith('ls', ['-la'], expect.objectContaining({ reject: false, timeout: 3000, cwd: '/project' }));
|
|
472
|
+
});
|
|
473
|
+
it('uses step-level cwd override instead of pipeline cwd', async () => {
|
|
474
|
+
vi.mocked(execa).mockResolvedValue(makeExecaResult({ stdout: '' }));
|
|
475
|
+
await runPipeline(baseParams({
|
|
476
|
+
steps: [shellStep(['pwd'], { cwd: '/override' })],
|
|
477
|
+
cwd: '/pipeline',
|
|
478
|
+
}));
|
|
479
|
+
expect(vi.mocked(execa)).toHaveBeenCalledWith('pwd', [], expect.objectContaining({ cwd: '/override' }));
|
|
480
|
+
});
|
|
481
|
+
it('falls back to pipeline cwd when step cwd is absent', async () => {
|
|
482
|
+
vi.mocked(execa).mockResolvedValue(makeExecaResult({ stdout: '' }));
|
|
483
|
+
await runPipeline(baseParams({
|
|
484
|
+
steps: [shellStep(['pwd'])],
|
|
485
|
+
cwd: '/pipeline',
|
|
486
|
+
}));
|
|
487
|
+
expect(vi.mocked(execa)).toHaveBeenCalledWith('pwd', [], expect.objectContaining({ cwd: '/pipeline' }));
|
|
488
|
+
});
|
|
489
|
+
it('dryRun skips execution and emits a redacted progress message', async () => {
|
|
490
|
+
const messages = [];
|
|
491
|
+
const result = await runPipeline(baseParams({
|
|
492
|
+
steps: [shellStep(['rm', '-rf', '/data'], { dryRun: true, id: 'cleanup' })],
|
|
493
|
+
onProgress: (msg) => messages.push(msg),
|
|
494
|
+
}));
|
|
495
|
+
expect(vi.mocked(execa)).not.toHaveBeenCalled();
|
|
496
|
+
expect(result.outputs).toEqual(['']);
|
|
497
|
+
expect(messages).toHaveLength(1);
|
|
498
|
+
expect(messages[0]).toContain('cleanup');
|
|
499
|
+
expect(messages[0]).toContain('rm');
|
|
500
|
+
// Must not contain any arg values
|
|
501
|
+
expect(messages[0]).not.toContain('-rf');
|
|
502
|
+
expect(messages[0]).not.toContain('/data');
|
|
503
|
+
});
|
|
504
|
+
it('dryRun message includes binary name and arg count', async () => {
|
|
505
|
+
const messages = [];
|
|
506
|
+
await runPipeline(baseParams({
|
|
507
|
+
steps: [shellStep(['git', 'commit', '-m', 'msg'], { dryRun: true })],
|
|
508
|
+
onProgress: (msg) => messages.push(msg),
|
|
509
|
+
}));
|
|
510
|
+
expect(messages[0]).toContain('git');
|
|
511
|
+
expect(messages[0]).toContain('3'); // 3 args
|
|
512
|
+
});
|
|
513
|
+
it('throws a validation error when shell command executable is missing', async () => {
|
|
514
|
+
await expect(runPipeline(baseParams({ steps: [shellStep([])] }))).rejects.toThrow('command must include a non-empty executable');
|
|
515
|
+
expect(vi.mocked(execa)).not.toHaveBeenCalled();
|
|
516
|
+
});
|
|
517
|
+
it('throws a validation error when interpolated executable resolves to empty', async () => {
|
|
518
|
+
await expect(runPipeline(baseParams({
|
|
519
|
+
steps: [
|
|
520
|
+
step(''),
|
|
521
|
+
shellStep(['{{prev.output}}']),
|
|
522
|
+
],
|
|
523
|
+
runtime: makeRuntime([{ type: 'text_final', text: '' }, { type: 'done' }]),
|
|
524
|
+
}))).rejects.toThrow('command resolved to an empty executable');
|
|
525
|
+
expect(vi.mocked(execa)).not.toHaveBeenCalled();
|
|
526
|
+
});
|
|
527
|
+
it('confirm=true without confirmAllowed throws before execution', async () => {
|
|
528
|
+
await expect(runPipeline(baseParams({
|
|
529
|
+
steps: [shellStep(['rm', '-rf', '/'], { confirm: true })],
|
|
530
|
+
}))).rejects.toThrow('confirm=true requires confirmAllowed');
|
|
531
|
+
expect(vi.mocked(execa)).not.toHaveBeenCalled();
|
|
532
|
+
});
|
|
533
|
+
it('confirm=true with confirmAllowed=true allows execution', async () => {
|
|
534
|
+
vi.mocked(execa).mockResolvedValue(makeExecaResult({ stdout: 'ok' }));
|
|
535
|
+
const result = await runPipeline(baseParams({
|
|
536
|
+
steps: [shellStep(['echo', 'ok'], { confirm: true })],
|
|
537
|
+
confirmAllowed: true,
|
|
538
|
+
}));
|
|
539
|
+
expect(result.outputs).toEqual(['ok']);
|
|
540
|
+
});
|
|
541
|
+
it('throws a sanitized timeout error without exposing command args', async () => {
|
|
542
|
+
vi.mocked(execa).mockResolvedValue(makeExecaResult({ timedOut: true, failed: true }));
|
|
543
|
+
await expect(runPipeline(baseParams({
|
|
544
|
+
steps: [shellStep(['sleep', '999'], { timeoutMs: 100 })],
|
|
545
|
+
}))).rejects.toThrow('timed out after 100ms');
|
|
546
|
+
});
|
|
547
|
+
it('throws a sanitized spawn-failure error', async () => {
|
|
548
|
+
vi.mocked(execa).mockResolvedValue(makeExecaResult({ failed: true, exitCode: null }));
|
|
549
|
+
await expect(runPipeline(baseParams({ steps: [shellStep(['nonexistent_binary_xyz'])] }))).rejects.toThrow('failed to spawn');
|
|
550
|
+
});
|
|
551
|
+
it('throws a sanitized non-zero exit error with exit code', async () => {
|
|
552
|
+
vi.mocked(execa).mockResolvedValue(makeExecaResult({ exitCode: 2, failed: true }));
|
|
553
|
+
await expect(runPipeline(baseParams({ steps: [shellStep(['false'])] }))).rejects.toThrow('exited with code 2');
|
|
554
|
+
});
|
|
555
|
+
it('includes signal name in non-zero exit error when signal is present', async () => {
|
|
556
|
+
vi.mocked(execa).mockResolvedValue(makeExecaResult({ exitCode: 1, failed: true, signal: 'SIGTERM' }));
|
|
557
|
+
await expect(runPipeline(baseParams({ steps: [shellStep(['cmd'])] }))).rejects.toThrow('signal: SIGTERM');
|
|
558
|
+
});
|
|
559
|
+
it('throws a sanitized canceled error', async () => {
|
|
560
|
+
vi.mocked(execa).mockResolvedValue(makeExecaResult({ isCanceled: true, failed: true, exitCode: null }));
|
|
561
|
+
await expect(runPipeline(baseParams({ steps: [shellStep(['sleep', '10'])] }))).rejects.toThrow('canceled');
|
|
562
|
+
});
|
|
563
|
+
it('onError skip: failed shell step is skipped and pipeline continues', async () => {
|
|
564
|
+
vi.mocked(execa)
|
|
565
|
+
.mockResolvedValueOnce(makeExecaResult({ exitCode: 1, failed: true }))
|
|
566
|
+
.mockResolvedValueOnce(makeExecaResult({ stdout: 'second-ok' }));
|
|
567
|
+
const result = await runPipeline(baseParams({
|
|
568
|
+
steps: [
|
|
569
|
+
shellStep(['false'], { onError: 'skip' }),
|
|
570
|
+
shellStep(['echo', 'second']),
|
|
571
|
+
],
|
|
572
|
+
}));
|
|
573
|
+
expect(result.outputs).toEqual(['', 'second-ok']);
|
|
574
|
+
});
|
|
575
|
+
it('interpolates {{prev.output}} in command args before execution', async () => {
|
|
576
|
+
vi.mocked(execa)
|
|
577
|
+
.mockResolvedValueOnce(makeExecaResult({ stdout: 'step-one-result' }))
|
|
578
|
+
.mockResolvedValueOnce(makeExecaResult({ stdout: '' }));
|
|
579
|
+
await runPipeline(baseParams({
|
|
580
|
+
steps: [
|
|
581
|
+
shellStep(['echo', 'step-one-result']),
|
|
582
|
+
shellStep(['process', '{{prev.output}}']),
|
|
583
|
+
],
|
|
584
|
+
}));
|
|
585
|
+
expect(vi.mocked(execa)).toHaveBeenNthCalledWith(2, 'process', ['step-one-result'], expect.anything());
|
|
586
|
+
});
|
|
587
|
+
it('interpolates {{steps.<id>.output}} in command args', async () => {
|
|
588
|
+
vi.mocked(execa)
|
|
589
|
+
.mockResolvedValueOnce(makeExecaResult({ stdout: 'artifact' }))
|
|
590
|
+
.mockResolvedValueOnce(makeExecaResult({ stdout: '' }))
|
|
591
|
+
.mockResolvedValueOnce(makeExecaResult({ stdout: '' }));
|
|
592
|
+
await runPipeline(baseParams({
|
|
593
|
+
steps: [
|
|
594
|
+
shellStep(['echo', 'artifact'], { id: 'build' }),
|
|
595
|
+
shellStep(['noop']),
|
|
596
|
+
shellStep(['deploy', '{{steps.build.output}}']),
|
|
597
|
+
],
|
|
598
|
+
}));
|
|
599
|
+
expect(vi.mocked(execa)).toHaveBeenNthCalledWith(3, 'deploy', ['artifact'], expect.anything());
|
|
600
|
+
});
|
|
601
|
+
it('shell step output is available to subsequent prompt steps via {{prev.output}}', async () => {
|
|
602
|
+
vi.mocked(execa).mockResolvedValue(makeExecaResult({ stdout: 'shell-result' }));
|
|
603
|
+
const capturedPrompts = [];
|
|
604
|
+
const runtime = {
|
|
605
|
+
id: 'other',
|
|
606
|
+
capabilities: new Set(['streaming_text']),
|
|
607
|
+
async *invoke(params) {
|
|
608
|
+
capturedPrompts.push(params.prompt);
|
|
609
|
+
yield { type: 'text_final', text: 'done' };
|
|
610
|
+
yield { type: 'done' };
|
|
611
|
+
},
|
|
612
|
+
};
|
|
613
|
+
await runPipeline(baseParams({
|
|
614
|
+
steps: [
|
|
615
|
+
shellStep(['get-data']),
|
|
616
|
+
step('summarize: {{prev.output}}'),
|
|
617
|
+
],
|
|
618
|
+
runtime,
|
|
619
|
+
}));
|
|
620
|
+
expect(capturedPrompts[0]).toBe('summarize: shell-result');
|
|
621
|
+
});
|
|
622
|
+
it('calls onProgress with step id after successful shell step', async () => {
|
|
623
|
+
vi.mocked(execa).mockResolvedValue(makeExecaResult({ stdout: '' }));
|
|
624
|
+
const messages = [];
|
|
625
|
+
await runPipeline(baseParams({
|
|
626
|
+
steps: [shellStep(['true'], { id: 'my-step' })],
|
|
627
|
+
onProgress: (msg) => messages.push(msg),
|
|
628
|
+
}));
|
|
629
|
+
expect(messages).toHaveLength(1);
|
|
630
|
+
expect(messages[0]).toContain('my-step');
|
|
631
|
+
});
|
|
632
|
+
it('error message wraps failure with step index', async () => {
|
|
633
|
+
vi.mocked(execa)
|
|
634
|
+
.mockResolvedValueOnce(makeExecaResult({ stdout: 'ok' }))
|
|
635
|
+
.mockResolvedValueOnce(makeExecaResult({ exitCode: 1, failed: true }));
|
|
636
|
+
await expect(runPipeline(baseParams({
|
|
637
|
+
steps: [
|
|
638
|
+
shellStep(['echo', 'first']),
|
|
639
|
+
shellStep(['false']),
|
|
640
|
+
],
|
|
641
|
+
}))).rejects.toThrow('Pipeline step 1 failed:');
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
// ---------------------------------------------------------------------------
|
|
645
|
+
// Discord action step tests
|
|
646
|
+
// ---------------------------------------------------------------------------
|
|
647
|
+
describe('discord-action steps', () => {
|
|
648
|
+
function discordStep(actions, overrides) {
|
|
649
|
+
return {
|
|
650
|
+
kind: 'discord-action',
|
|
651
|
+
actions,
|
|
652
|
+
execute: vi.fn().mockResolvedValue([{ ok: true, summary: 'done' }]),
|
|
653
|
+
...overrides,
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
it('runs execute and stores JSON-serialized results as output', async () => {
|
|
657
|
+
const execute = vi.fn().mockResolvedValue([{ ok: true, summary: 'sent' }]);
|
|
658
|
+
const result = await runPipeline(baseParams({
|
|
659
|
+
steps: [discordStep([{ type: 'sendMessage', content: 'hello' }], { execute })],
|
|
660
|
+
}));
|
|
661
|
+
expect(execute).toHaveBeenCalledWith([{ type: 'sendMessage', content: 'hello' }]);
|
|
662
|
+
expect(result.outputs).toEqual([JSON.stringify([{ ok: true, summary: 'sent' }])]);
|
|
663
|
+
});
|
|
664
|
+
it('resolves actions callback with step context', async () => {
|
|
665
|
+
let capturedCtx;
|
|
666
|
+
const execute = vi.fn().mockResolvedValue([{ ok: true }]);
|
|
667
|
+
await runPipeline(baseParams({
|
|
668
|
+
steps: [
|
|
669
|
+
step('first'),
|
|
670
|
+
discordStep((ctx) => {
|
|
671
|
+
capturedCtx = ctx;
|
|
672
|
+
return [{ type: 'sendMessage', content: ctx.previousOutput }];
|
|
673
|
+
}, { execute }),
|
|
674
|
+
],
|
|
675
|
+
runtime: makeRuntime([{ type: 'text_final', text: 'hello' }, { type: 'done' }]),
|
|
676
|
+
}));
|
|
677
|
+
expect(capturedCtx?.stepIndex).toBe(1);
|
|
678
|
+
expect(capturedCtx?.previousOutput).toBe('hello');
|
|
679
|
+
expect(execute).toHaveBeenCalledWith([{ type: 'sendMessage', content: 'hello' }]);
|
|
680
|
+
});
|
|
681
|
+
it('interpolates {{prev.output}} in action string values', async () => {
|
|
682
|
+
const execute = vi.fn().mockResolvedValue([{ ok: true }]);
|
|
683
|
+
await runPipeline(baseParams({
|
|
684
|
+
steps: [
|
|
685
|
+
step('world'),
|
|
686
|
+
discordStep([{ type: 'sendMessage', content: 'hello {{prev.output}}' }], { execute }),
|
|
687
|
+
],
|
|
688
|
+
runtime: makeRuntime([{ type: 'text_final', text: 'world' }, { type: 'done' }]),
|
|
689
|
+
}));
|
|
690
|
+
expect(execute).toHaveBeenCalledWith([{ type: 'sendMessage', content: 'hello world' }]);
|
|
691
|
+
});
|
|
692
|
+
it('interpolates {{steps.<id>.output}} in action string values', async () => {
|
|
693
|
+
const execute = vi.fn().mockResolvedValue([{ ok: true }]);
|
|
694
|
+
await runPipeline(baseParams({
|
|
695
|
+
steps: [
|
|
696
|
+
step('ref-value', { id: 'src' }),
|
|
697
|
+
step('ignored'),
|
|
698
|
+
discordStep([{ type: 'taskUpdate', note: '{{steps.src.output}}' }], { execute }),
|
|
699
|
+
],
|
|
700
|
+
runtime: makeRuntime([{ type: 'text_final', text: 'ref-value' }, { type: 'done' }]),
|
|
701
|
+
}));
|
|
702
|
+
expect(execute).toHaveBeenCalledWith([{ type: 'taskUpdate', note: 'ref-value' }]);
|
|
703
|
+
});
|
|
704
|
+
it('interpolates string values inside nested objects and arrays', async () => {
|
|
705
|
+
const execute = vi.fn().mockResolvedValue([{ ok: true }]);
|
|
706
|
+
await runPipeline(baseParams({
|
|
707
|
+
steps: [
|
|
708
|
+
step('v'),
|
|
709
|
+
discordStep([{ type: 'taskUpdate', nested: { key: '{{prev.output}}' }, tags: ['tag-{{prev.output}}'] }], { execute }),
|
|
710
|
+
],
|
|
711
|
+
runtime: makeRuntime([{ type: 'text_final', text: 'v' }, { type: 'done' }]),
|
|
712
|
+
}));
|
|
713
|
+
expect(execute).toHaveBeenCalledWith([
|
|
714
|
+
{ type: 'taskUpdate', nested: { key: 'v' }, tags: ['tag-v'] },
|
|
715
|
+
]);
|
|
716
|
+
});
|
|
717
|
+
it('throws when any result entry has ok: false', async () => {
|
|
718
|
+
const execute = vi.fn().mockResolvedValue([
|
|
719
|
+
{ ok: true },
|
|
720
|
+
{ ok: false, error: 'channel not found' },
|
|
721
|
+
]);
|
|
722
|
+
await expect(runPipeline(baseParams({
|
|
723
|
+
steps: [discordStep([{ type: 'react' }, { type: 'sendMessage' }], { execute })],
|
|
724
|
+
}))).rejects.toThrow('channel not found');
|
|
725
|
+
});
|
|
726
|
+
it('wraps failure with Pipeline step index', async () => {
|
|
727
|
+
const execute = vi.fn().mockResolvedValue([{ ok: false, error: 'boom' }]);
|
|
728
|
+
await expect(runPipeline(baseParams({
|
|
729
|
+
steps: [discordStep([], { execute })],
|
|
730
|
+
}))).rejects.toThrow('Pipeline step 0 failed: boom');
|
|
731
|
+
});
|
|
732
|
+
it('onError skip: failed discord-action step is skipped and pipeline continues', async () => {
|
|
733
|
+
const execute = vi.fn().mockResolvedValue([{ ok: false, error: 'boom' }]);
|
|
734
|
+
const result = await runPipeline(baseParams({
|
|
735
|
+
steps: [
|
|
736
|
+
discordStep([], { execute, onError: 'skip' }),
|
|
737
|
+
step('next'),
|
|
738
|
+
],
|
|
739
|
+
runtime: makeRuntime([{ type: 'text_final', text: 'ok' }, { type: 'done' }]),
|
|
740
|
+
}));
|
|
741
|
+
expect(result.outputs).toEqual(['', 'ok']);
|
|
742
|
+
});
|
|
743
|
+
it('calls onProgress after successful discord-action step', async () => {
|
|
744
|
+
const messages = [];
|
|
745
|
+
const execute = vi.fn().mockResolvedValue([{ ok: true }]);
|
|
746
|
+
await runPipeline(baseParams({
|
|
747
|
+
steps: [discordStep([], { id: 'notify', execute })],
|
|
748
|
+
onProgress: (msg) => messages.push(msg),
|
|
749
|
+
}));
|
|
750
|
+
expect(messages).toHaveLength(1);
|
|
751
|
+
expect(messages[0]).toContain('notify');
|
|
752
|
+
});
|
|
753
|
+
it('calls onProgress for skipped discord-action step', async () => {
|
|
754
|
+
const messages = [];
|
|
755
|
+
const execute = vi.fn().mockResolvedValue([{ ok: false, error: 'err' }]);
|
|
756
|
+
await runPipeline(baseParams({
|
|
757
|
+
steps: [discordStep([], { id: 'skipped-step', execute, onError: 'skip' })],
|
|
758
|
+
onProgress: (msg) => messages.push(msg),
|
|
759
|
+
}));
|
|
760
|
+
expect(messages).toHaveLength(1);
|
|
761
|
+
expect(messages[0]).toContain('skipped-step');
|
|
762
|
+
});
|
|
763
|
+
it('non-string leaves in action objects are passed through unchanged', async () => {
|
|
764
|
+
const execute = vi.fn().mockResolvedValue([{ ok: true }]);
|
|
765
|
+
await runPipeline(baseParams({
|
|
766
|
+
steps: [discordStep([{ type: 'react', count: 42, active: true, data: null }], { execute })],
|
|
767
|
+
}));
|
|
768
|
+
expect(execute).toHaveBeenCalledWith([{ type: 'react', count: 42, active: true, data: null }]);
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
});
|