lastlight 0.6.2 → 0.7.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/dist/admin/routes.d.ts +1 -1
- package/dist/admin/routes.js +1 -1
- package/dist/admin/routes.js.map +1 -1
- package/dist/cli/cli-config.js.map +1 -0
- package/dist/cli/cli-format.js.map +1 -0
- package/dist/{cli-server.d.ts → cli/cli-server.d.ts} +1 -1
- package/dist/{cli-server.js → cli/cli-server.js} +2 -2
- package/dist/cli/cli-server.js.map +1 -0
- package/dist/cli/cli-timeline.js.map +1 -0
- package/dist/{cli.js → cli/cli.js} +2 -0
- package/dist/cli/cli.js.map +1 -0
- package/dist/{fork-cli.d.ts → cli/fork-cli.d.ts} +14 -5
- package/dist/{fork-cli.js → cli/fork-cli.js} +86 -16
- package/dist/cli/fork-cli.js.map +1 -0
- package/dist/{setup.js → cli/setup.js} +1 -1
- package/dist/cli/setup.js.map +1 -0
- package/dist/{skills-install.js → cli/skills-install.js} +1 -1
- package/dist/cli/skills-install.js.map +1 -0
- package/dist/config/config-resolve.js.map +1 -0
- package/dist/{config.js → config/config.js} +2 -2
- package/dist/config/config.js.map +1 -0
- package/dist/config/overlay-assets.js.map +1 -0
- package/dist/config/overlay-bootstrap.js.map +1 -0
- package/dist/engine/agent-executor.d.ts +1 -1
- package/dist/engine/agent-executor.js +2 -2
- package/dist/engine/agent-executor.js.map +1 -1
- package/dist/engine/{chat-runner.d.ts → chat/chat-runner.d.ts} +2 -2
- package/dist/engine/{chat-runner.js → chat/chat-runner.js} +1 -1
- package/dist/engine/chat/chat-runner.js.map +1 -0
- package/dist/engine/{chat-skills.js → chat/chat-skills.js} +1 -1
- package/dist/engine/chat/chat-skills.js.map +1 -0
- package/dist/engine/{chat.d.ts → chat/chat.d.ts} +2 -2
- package/dist/engine/{chat.js → chat/chat.js} +7 -7
- package/dist/engine/chat/chat.js.map +1 -0
- package/dist/engine/{message-batcher.d.ts → chat/message-batcher.d.ts} +1 -1
- package/dist/engine/chat/message-batcher.js.map +1 -0
- package/dist/engine/dispatcher.d.ts +2 -2
- package/dist/engine/executors/backends.d.ts +2 -2
- package/dist/engine/executors/backends.js +1 -1
- package/dist/engine/executors/backends.js.map +1 -1
- package/dist/engine/executors/shared.d.ts +1 -1
- package/dist/engine/github/git-auth.js.map +1 -0
- package/dist/engine/github/github-app-client.js.map +1 -0
- package/dist/engine/github/github-tools.js.map +1 -0
- package/dist/engine/github/github.js.map +1 -0
- package/dist/engine/{profiles.d.ts → github/profiles.d.ts} +1 -1
- package/dist/engine/{profiles.js → github/profiles.js} +1 -1
- package/dist/engine/github/profiles.js.map +1 -0
- package/dist/engine/router.js +3 -3
- package/dist/engine/router.js.map +1 -1
- package/dist/engine/{classifier.d.ts → screen/classifier.d.ts} +1 -1
- package/dist/engine/{classifier.js → screen/classifier.js} +1 -1
- package/dist/engine/screen/classifier.js.map +1 -0
- package/dist/engine/{screen.d.ts → screen/screen.d.ts} +1 -1
- package/dist/engine/{screen.js → screen/screen.js} +1 -1
- package/dist/engine/screen/screen.js.map +1 -0
- package/dist/evals-api.d.ts +3 -3
- package/dist/evals-api.js +1 -1
- package/dist/evals-api.js.map +1 -1
- package/dist/index.js +8 -8
- package/dist/index.js.map +1 -1
- package/dist/managed-repos.js +1 -1
- package/dist/managed-repos.js.map +1 -1
- package/dist/notify/transports/github.d.ts +1 -1
- package/dist/telemetry/index.d.ts +1 -1
- package/dist/workflows/loader.d.ts +1 -1
- package/dist/workflows/phase-executor.d.ts +1 -1
- package/dist/workflows/resume.d.ts +3 -3
- package/dist/workflows/runner.d.ts +2 -2
- package/dist/workflows/runner.js +1 -1
- package/dist/workflows/runner.js.map +1 -1
- package/dist/workflows/schema.d.ts +2 -2
- package/dist/workflows/simple.d.ts +2 -2
- package/dist/workflows/simple.js +1 -1
- package/dist/workflows/simple.js.map +1 -1
- package/dist/workflows/triggers.js +1 -1
- package/dist/workflows/triggers.js.map +1 -1
- package/package.json +4 -3
- package/plugins/lastlight/.claude-plugin/plugin.json +1 -1
- package/plugins/lastlight/skills/lastlight-overlay/SKILL.md +7 -1
- package/plugins/lastlight/skills/lastlight-overlay/references/forking.md +23 -4
- package/dist/admin/auth.test.d.ts +0 -1
- package/dist/admin/auth.test.js +0 -60
- package/dist/admin/auth.test.js.map +0 -1
- package/dist/admin/log-search.test.d.ts +0 -1
- package/dist/admin/log-search.test.js +0 -78
- package/dist/admin/log-search.test.js.map +0 -1
- package/dist/admin/routes.test.d.ts +0 -1
- package/dist/admin/routes.test.js +0 -877
- package/dist/admin/routes.test.js.map +0 -1
- package/dist/admin/server-logs.test.d.ts +0 -1
- package/dist/admin/server-logs.test.js +0 -58
- package/dist/admin/server-logs.test.js.map +0 -1
- package/dist/admin/sessions.test.d.ts +0 -1
- package/dist/admin/sessions.test.js +0 -78
- package/dist/admin/sessions.test.js.map +0 -1
- package/dist/admin/version.test.d.ts +0 -1
- package/dist/admin/version.test.js +0 -71
- package/dist/admin/version.test.js.map +0 -1
- package/dist/cli-config.js.map +0 -1
- package/dist/cli-config.test.d.ts +0 -1
- package/dist/cli-config.test.js +0 -92
- package/dist/cli-config.test.js.map +0 -1
- package/dist/cli-format.js.map +0 -1
- package/dist/cli-server.js.map +0 -1
- package/dist/cli-server.test.d.ts +0 -1
- package/dist/cli-server.test.js +0 -41
- package/dist/cli-server.test.js.map +0 -1
- package/dist/cli-timeline.js.map +0 -1
- package/dist/cli-timeline.test.d.ts +0 -1
- package/dist/cli-timeline.test.js +0 -104
- package/dist/cli-timeline.test.js.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/config-overlay.test.d.ts +0 -1
- package/dist/config-overlay.test.js +0 -112
- package/dist/config-overlay.test.js.map +0 -1
- package/dist/config-resolve.js.map +0 -1
- package/dist/config-resolve.test.d.ts +0 -1
- package/dist/config-resolve.test.js +0 -68
- package/dist/config-resolve.test.js.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/config.test.d.ts +0 -1
- package/dist/config.test.js +0 -288
- package/dist/config.test.js.map +0 -1
- package/dist/connectors/github-webhook.test.d.ts +0 -1
- package/dist/connectors/github-webhook.test.js +0 -156
- package/dist/connectors/github-webhook.test.js.map +0 -1
- package/dist/connectors/messaging/session-manager.test.d.ts +0 -1
- package/dist/connectors/messaging/session-manager.test.js +0 -144
- package/dist/connectors/messaging/session-manager.test.js.map +0 -1
- package/dist/connectors/slack/connector.test.d.ts +0 -1
- package/dist/connectors/slack/connector.test.js +0 -135
- package/dist/connectors/slack/connector.test.js.map +0 -1
- package/dist/connectors/slack/mrkdwn.test.d.ts +0 -1
- package/dist/connectors/slack/mrkdwn.test.js +0 -114
- package/dist/connectors/slack/mrkdwn.test.js.map +0 -1
- package/dist/cron/fanout.test.d.ts +0 -1
- package/dist/cron/fanout.test.js +0 -73
- package/dist/cron/fanout.test.js.map +0 -1
- package/dist/engine/agent-executor.seam.test.d.ts +0 -1
- package/dist/engine/agent-executor.seam.test.js +0 -57
- package/dist/engine/agent-executor.seam.test.js.map +0 -1
- package/dist/engine/agent-executor.test.d.ts +0 -1
- package/dist/engine/agent-executor.test.js +0 -395
- package/dist/engine/agent-executor.test.js.map +0 -1
- package/dist/engine/chat-runner.js.map +0 -1
- package/dist/engine/chat-runner.test.d.ts +0 -1
- package/dist/engine/chat-runner.test.js +0 -104
- package/dist/engine/chat-runner.test.js.map +0 -1
- package/dist/engine/chat-skills.js.map +0 -1
- package/dist/engine/chat-skills.test.d.ts +0 -1
- package/dist/engine/chat-skills.test.js +0 -20
- package/dist/engine/chat-skills.test.js.map +0 -1
- package/dist/engine/chat.js.map +0 -1
- package/dist/engine/chat.test.d.ts +0 -1
- package/dist/engine/chat.test.js +0 -42
- package/dist/engine/chat.test.js.map +0 -1
- package/dist/engine/classifier.js.map +0 -1
- package/dist/engine/classifier.test.d.ts +0 -1
- package/dist/engine/classifier.test.js +0 -115
- package/dist/engine/classifier.test.js.map +0 -1
- package/dist/engine/dispatcher.test.d.ts +0 -1
- package/dist/engine/dispatcher.test.js +0 -511
- package/dist/engine/dispatcher.test.js.map +0 -1
- package/dist/engine/event-shim.test.d.ts +0 -1
- package/dist/engine/event-shim.test.js +0 -194
- package/dist/engine/event-shim.test.js.map +0 -1
- package/dist/engine/git-auth.js.map +0 -1
- package/dist/engine/git-auth.test.d.ts +0 -1
- package/dist/engine/git-auth.test.js +0 -117
- package/dist/engine/git-auth.test.js.map +0 -1
- package/dist/engine/github-app-client.js.map +0 -1
- package/dist/engine/github-app-client.test.d.ts +0 -1
- package/dist/engine/github-app-client.test.js +0 -44
- package/dist/engine/github-app-client.test.js.map +0 -1
- package/dist/engine/github-tools.js.map +0 -1
- package/dist/engine/github.js.map +0 -1
- package/dist/engine/llm.test.d.ts +0 -1
- package/dist/engine/llm.test.js +0 -189
- package/dist/engine/llm.test.js.map +0 -1
- package/dist/engine/message-batcher.js.map +0 -1
- package/dist/engine/message-batcher.test.d.ts +0 -1
- package/dist/engine/message-batcher.test.js +0 -109
- package/dist/engine/message-batcher.test.js.map +0 -1
- package/dist/engine/profiles.js.map +0 -1
- package/dist/engine/router.test.d.ts +0 -1
- package/dist/engine/router.test.js +0 -695
- package/dist/engine/router.test.js.map +0 -1
- package/dist/engine/screen.js.map +0 -1
- package/dist/engine/screen.test.d.ts +0 -1
- package/dist/engine/screen.test.js +0 -82
- package/dist/engine/screen.test.js.map +0 -1
- package/dist/fork-cli.js.map +0 -1
- package/dist/fork-cli.test.d.ts +0 -1
- package/dist/fork-cli.test.js +0 -109
- package/dist/fork-cli.test.js.map +0 -1
- package/dist/managed-repos.test.d.ts +0 -1
- package/dist/managed-repos.test.js +0 -39
- package/dist/managed-repos.test.js.map +0 -1
- package/dist/notify/model.test.d.ts +0 -1
- package/dist/notify/model.test.js +0 -109
- package/dist/notify/model.test.js.map +0 -1
- package/dist/notify/notifier.test.d.ts +0 -1
- package/dist/notify/notifier.test.js +0 -100
- package/dist/notify/notifier.test.js.map +0 -1
- package/dist/notify/render.test.d.ts +0 -1
- package/dist/notify/render.test.js +0 -65
- package/dist/notify/render.test.js.map +0 -1
- package/dist/notify/transports.test.d.ts +0 -1
- package/dist/notify/transports.test.js +0 -71
- package/dist/notify/transports.test.js.map +0 -1
- package/dist/overlay-assets.js.map +0 -1
- package/dist/overlay-assets.test.d.ts +0 -1
- package/dist/overlay-assets.test.js +0 -75
- package/dist/overlay-assets.test.js.map +0 -1
- package/dist/overlay-bootstrap.js.map +0 -1
- package/dist/overlay-bootstrap.test.d.ts +0 -1
- package/dist/overlay-bootstrap.test.js +0 -61
- package/dist/overlay-bootstrap.test.js.map +0 -1
- package/dist/sandbox/command-exec.integration.test.d.ts +0 -1
- package/dist/sandbox/command-exec.integration.test.js +0 -183
- package/dist/sandbox/command-exec.integration.test.js.map +0 -1
- package/dist/sandbox/docker-compose.test.d.ts +0 -1
- package/dist/sandbox/docker-compose.test.js +0 -128
- package/dist/sandbox/docker-compose.test.js.map +0 -1
- package/dist/sandbox/docker.test.d.ts +0 -1
- package/dist/sandbox/docker.test.js +0 -137
- package/dist/sandbox/docker.test.js.map +0 -1
- package/dist/sandbox/egress-allowlist.test.d.ts +0 -1
- package/dist/sandbox/egress-allowlist.test.js +0 -65
- package/dist/sandbox/egress-allowlist.test.js.map +0 -1
- package/dist/sandbox/egress-firewall-config.test.d.ts +0 -1
- package/dist/sandbox/egress-firewall-config.test.js +0 -244
- package/dist/sandbox/egress-firewall-config.test.js.map +0 -1
- package/dist/sandbox/index.test.d.ts +0 -1
- package/dist/sandbox/index.test.js +0 -157
- package/dist/sandbox/index.test.js.map +0 -1
- package/dist/sandbox/smol.integration.test.d.ts +0 -1
- package/dist/sandbox/smol.integration.test.js +0 -130
- package/dist/sandbox/smol.integration.test.js.map +0 -1
- package/dist/session-log.test.d.ts +0 -1
- package/dist/session-log.test.js +0 -85
- package/dist/session-log.test.js.map +0 -1
- package/dist/setup.js.map +0 -1
- package/dist/setup.test.d.ts +0 -1
- package/dist/setup.test.js +0 -281
- package/dist/setup.test.js.map +0 -1
- package/dist/skills-install.js.map +0 -1
- package/dist/state/build-assets.test.d.ts +0 -1
- package/dist/state/build-assets.test.js +0 -106
- package/dist/state/build-assets.test.js.map +0 -1
- package/dist/state/db.test.d.ts +0 -1
- package/dist/state/db.test.js +0 -484
- package/dist/state/db.test.js.map +0 -1
- package/dist/state/workflow-run-store.test.d.ts +0 -1
- package/dist/state/workflow-run-store.test.js +0 -264
- package/dist/state/workflow-run-store.test.js.map +0 -1
- package/dist/telemetry/index.test.d.ts +0 -1
- package/dist/telemetry/index.test.js +0 -73
- package/dist/telemetry/index.test.js.map +0 -1
- package/dist/telemetry/pi-events.test.d.ts +0 -1
- package/dist/telemetry/pi-events.test.js +0 -69
- package/dist/telemetry/pi-events.test.js.map +0 -1
- package/dist/workflows/dag.test.d.ts +0 -1
- package/dist/workflows/dag.test.js +0 -197
- package/dist/workflows/dag.test.js.map +0 -1
- package/dist/workflows/golden-build.test.d.ts +0 -1
- package/dist/workflows/golden-build.test.js +0 -45
- package/dist/workflows/golden-build.test.js.map +0 -1
- package/dist/workflows/loader-overlay.test.d.ts +0 -1
- package/dist/workflows/loader-overlay.test.js +0 -101
- package/dist/workflows/loader-overlay.test.js.map +0 -1
- package/dist/workflows/loader.test.d.ts +0 -1
- package/dist/workflows/loader.test.js +0 -375
- package/dist/workflows/loader.test.js.map +0 -1
- package/dist/workflows/loop-eval.test.d.ts +0 -1
- package/dist/workflows/loop-eval.test.js +0 -89
- package/dist/workflows/loop-eval.test.js.map +0 -1
- package/dist/workflows/phase-executor.test.d.ts +0 -1
- package/dist/workflows/phase-executor.test.js +0 -497
- package/dist/workflows/phase-executor.test.js.map +0 -1
- package/dist/workflows/phase-ref.test.d.ts +0 -1
- package/dist/workflows/phase-ref.test.js +0 -24
- package/dist/workflows/phase-ref.test.js.map +0 -1
- package/dist/workflows/runner.test.d.ts +0 -1
- package/dist/workflows/runner.test.js +0 -1174
- package/dist/workflows/runner.test.js.map +0 -1
- package/dist/workflows/simple.test.d.ts +0 -1
- package/dist/workflows/simple.test.js +0 -107
- package/dist/workflows/simple.test.js.map +0 -1
- package/dist/workflows/templates.test.d.ts +0 -1
- package/dist/workflows/templates.test.js +0 -258
- package/dist/workflows/templates.test.js.map +0 -1
- package/dist/workflows/verdict.test.d.ts +0 -1
- package/dist/workflows/verdict.test.js +0 -44
- package/dist/workflows/verdict.test.js.map +0 -1
- package/dist/worktree/manager.test.d.ts +0 -1
- package/dist/worktree/manager.test.js +0 -87
- package/dist/worktree/manager.test.js.map +0 -1
- /package/dist/{cli-config.d.ts → cli/cli-config.d.ts} +0 -0
- /package/dist/{cli-config.js → cli/cli-config.js} +0 -0
- /package/dist/{cli-format.d.ts → cli/cli-format.d.ts} +0 -0
- /package/dist/{cli-format.js → cli/cli-format.js} +0 -0
- /package/dist/{cli-timeline.d.ts → cli/cli-timeline.d.ts} +0 -0
- /package/dist/{cli-timeline.js → cli/cli-timeline.js} +0 -0
- /package/dist/{cli.d.ts → cli/cli.d.ts} +0 -0
- /package/dist/{setup.d.ts → cli/setup.d.ts} +0 -0
- /package/dist/{skills-install.d.ts → cli/skills-install.d.ts} +0 -0
- /package/dist/{config-resolve.d.ts → config/config-resolve.d.ts} +0 -0
- /package/dist/{config-resolve.js → config/config-resolve.js} +0 -0
- /package/dist/{config.d.ts → config/config.d.ts} +0 -0
- /package/dist/{overlay-assets.d.ts → config/overlay-assets.d.ts} +0 -0
- /package/dist/{overlay-assets.js → config/overlay-assets.js} +0 -0
- /package/dist/{overlay-bootstrap.d.ts → config/overlay-bootstrap.d.ts} +0 -0
- /package/dist/{overlay-bootstrap.js → config/overlay-bootstrap.js} +0 -0
- /package/dist/engine/{chat-skills.d.ts → chat/chat-skills.d.ts} +0 -0
- /package/dist/engine/{message-batcher.js → chat/message-batcher.js} +0 -0
- /package/dist/engine/{git-auth.d.ts → github/git-auth.d.ts} +0 -0
- /package/dist/engine/{git-auth.js → github/git-auth.js} +0 -0
- /package/dist/engine/{github-app-client.d.ts → github/github-app-client.d.ts} +0 -0
- /package/dist/engine/{github-app-client.js → github/github-app-client.js} +0 -0
- /package/dist/engine/{github-tools.d.ts → github/github-tools.d.ts} +0 -0
- /package/dist/engine/{github-tools.js → github/github-tools.js} +0 -0
- /package/dist/engine/{github.d.ts → github/github.d.ts} +0 -0
- /package/dist/engine/{github.js → github/github.js} +0 -0
|
@@ -1,1174 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
// Mock the executor so we don't make real agent calls. `executeCommand` backs
|
|
3
|
-
// both `type: bash`/`script` phases and the in-sandbox `until_bash` check.
|
|
4
|
-
vi.mock("../engine/agent-executor.js", () => ({
|
|
5
|
-
executeAgent: vi.fn(),
|
|
6
|
-
executeCommand: vi.fn(),
|
|
7
|
-
}));
|
|
8
|
-
// Mock the docker module
|
|
9
|
-
vi.mock("../admin/docker.js", () => ({
|
|
10
|
-
listRunningContainers: vi.fn(async () => []),
|
|
11
|
-
}));
|
|
12
|
-
// Mock the loader so templates come from strings, not files
|
|
13
|
-
vi.mock("./loader.js", () => ({
|
|
14
|
-
loadPromptTemplate: vi.fn((path) => `TEMPLATE:${path}`),
|
|
15
|
-
}));
|
|
16
|
-
// Keep child_process mocked so `qaImageAvailable()` (images.ts) sees no
|
|
17
|
-
// `execFileSync` and reports the QA image as unavailable in tests. until_bash no
|
|
18
|
-
// longer uses execSync (it runs via the mocked executeCommand), but this mock
|
|
19
|
-
// must stay for the sandbox_image:qa skip test.
|
|
20
|
-
vi.mock("child_process", () => ({ execSync: vi.fn() }));
|
|
21
|
-
import { executeAgent, executeCommand } from "../engine/agent-executor.js";
|
|
22
|
-
import { loadPromptTemplate } from "./loader.js";
|
|
23
|
-
import { runWorkflow, gitAccessProfileForWorkflow, gitSandboxAccessForWorkflow } from "./runner.js";
|
|
24
|
-
const mockExecuteAgent = vi.mocked(executeAgent);
|
|
25
|
-
const mockExecuteCommand = vi.mocked(executeCommand);
|
|
26
|
-
const mockLoadPromptTemplate = vi.mocked(loadPromptTemplate);
|
|
27
|
-
const BASE_CTX = {
|
|
28
|
-
owner: "acme",
|
|
29
|
-
repo: "widget",
|
|
30
|
-
issueNumber: 42,
|
|
31
|
-
issueTitle: "Add Rate Limiter",
|
|
32
|
-
issueBody: "We need a rate limiter",
|
|
33
|
-
issueLabels: [],
|
|
34
|
-
commentBody: "",
|
|
35
|
-
sender: "alice",
|
|
36
|
-
branch: "lastlight/42-add-rate-limiter",
|
|
37
|
-
taskId: "widget-42",
|
|
38
|
-
issueDir: ".lastlight/issue-42",
|
|
39
|
-
bootstrapLabel: "lastlight:bootstrap",
|
|
40
|
-
};
|
|
41
|
-
function makeSuccessResult(output = "success output") {
|
|
42
|
-
return {
|
|
43
|
-
success: true,
|
|
44
|
-
output,
|
|
45
|
-
error: undefined,
|
|
46
|
-
turns: 5,
|
|
47
|
-
durationMs: 1000,
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
function makeFailResult(error = "something went wrong") {
|
|
51
|
-
return {
|
|
52
|
-
success: false,
|
|
53
|
-
output: "",
|
|
54
|
-
error,
|
|
55
|
-
turns: 2,
|
|
56
|
-
durationMs: 500,
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
const SIMPLE_WORKFLOW = {
|
|
60
|
-
kind: "agent",
|
|
61
|
-
name: "simple",
|
|
62
|
-
phases: [
|
|
63
|
-
{ name: "phase_0", type: "context" },
|
|
64
|
-
{ name: "architect", type: "agent", prompt: "prompts/architect.md" },
|
|
65
|
-
{ name: "executor", type: "agent", prompt: "prompts/executor.md" },
|
|
66
|
-
],
|
|
67
|
-
};
|
|
68
|
-
const WORKFLOW_WITH_GUARDRAILS = {
|
|
69
|
-
kind: "agent",
|
|
70
|
-
name: "guarded",
|
|
71
|
-
phases: [
|
|
72
|
-
{ name: "phase_0", type: "context" },
|
|
73
|
-
{
|
|
74
|
-
name: "guardrails",
|
|
75
|
-
type: "agent",
|
|
76
|
-
prompt: "prompts/guardrails.md",
|
|
77
|
-
on_output: {
|
|
78
|
-
contains_BLOCKED: {
|
|
79
|
-
action: "fail",
|
|
80
|
-
message: "Guardrails check: BLOCKED",
|
|
81
|
-
unless_label: "lastlight:bootstrap",
|
|
82
|
-
},
|
|
83
|
-
contains_READY: { action: "continue" },
|
|
84
|
-
},
|
|
85
|
-
},
|
|
86
|
-
{ name: "architect", type: "agent", prompt: "prompts/architect.md" },
|
|
87
|
-
],
|
|
88
|
-
};
|
|
89
|
-
const WORKFLOW_WITH_REVIEWER_LOOP = {
|
|
90
|
-
kind: "agent",
|
|
91
|
-
name: "full",
|
|
92
|
-
phases: [
|
|
93
|
-
{ name: "phase_0", type: "context" },
|
|
94
|
-
{ name: "executor", type: "agent", prompt: "prompts/executor.md" },
|
|
95
|
-
{
|
|
96
|
-
name: "reviewer",
|
|
97
|
-
type: "agent",
|
|
98
|
-
prompt: "prompts/reviewer.md",
|
|
99
|
-
loop: {
|
|
100
|
-
max_cycles: 2,
|
|
101
|
-
on_request_changes: {
|
|
102
|
-
fix_prompt: "prompts/fix.md",
|
|
103
|
-
re_review_prompt: "prompts/re-reviewer.md",
|
|
104
|
-
},
|
|
105
|
-
},
|
|
106
|
-
},
|
|
107
|
-
{ name: "pr", type: "agent", prompt: "prompts/pr.md", on_success: { set_phase: "complete" } },
|
|
108
|
-
],
|
|
109
|
-
};
|
|
110
|
-
describe("runWorkflow — basic phase execution", () => {
|
|
111
|
-
beforeEach(() => {
|
|
112
|
-
vi.clearAllMocks();
|
|
113
|
-
});
|
|
114
|
-
it("executes context phase without calling executeAgent", async () => {
|
|
115
|
-
mockExecuteAgent.mockResolvedValue(makeSuccessResult());
|
|
116
|
-
const result = await runWorkflow({
|
|
117
|
-
kind: "agent",
|
|
118
|
-
name: "ctx-only",
|
|
119
|
-
phases: [{ name: "phase_0", type: "context" }],
|
|
120
|
-
}, BASE_CTX, {}, {});
|
|
121
|
-
expect(mockExecuteAgent).not.toHaveBeenCalled();
|
|
122
|
-
expect(result.phases).toHaveLength(1);
|
|
123
|
-
expect(result.phases[0].phase).toBe("phase_0");
|
|
124
|
-
expect(result.phases[0].success).toBe(true);
|
|
125
|
-
});
|
|
126
|
-
// Regression: a fresh workflow row has currentPhase initialized to
|
|
127
|
-
// phases[0].name. The runner's resume logic must not interpret that as
|
|
128
|
-
// "phase_0 already completed" and skip it — phase_history is empty on
|
|
129
|
-
// a fresh run. Symptom in prod: explore workflow's context phase was
|
|
130
|
-
// silently skipped, so phase_history stayed [] and downstream failures
|
|
131
|
-
// surfaced with current_phase=phase_0 even though phase_0 never ran.
|
|
132
|
-
it("runs the first phase on a fresh run despite currentPhase=phase_0 in DB", async () => {
|
|
133
|
-
mockExecuteAgent.mockResolvedValue(makeSuccessResult());
|
|
134
|
-
const db = makeMockDb("phase_0");
|
|
135
|
-
const result = await runWorkflow(SIMPLE_WORKFLOW, BASE_CTX, {}, {}, db, undefined, undefined, "wf-fresh-1");
|
|
136
|
-
expect(result.success).toBe(true);
|
|
137
|
-
// Context phase ran (persistPhase fired) and both agent phases ran.
|
|
138
|
-
expect(result.phases.map((p) => p.phase)).toEqual(["phase_0", "architect", "executor"]);
|
|
139
|
-
expect(db.runs.appendPhase).toHaveBeenCalledWith("wf-fresh-1", "phase_0", expect.objectContaining({ phase: "phase_0", success: true }));
|
|
140
|
-
});
|
|
141
|
-
it("executes agent phases in order", async () => {
|
|
142
|
-
const calls = [];
|
|
143
|
-
mockExecuteAgent.mockImplementation(async (prompt) => {
|
|
144
|
-
calls.push(prompt);
|
|
145
|
-
return makeSuccessResult();
|
|
146
|
-
});
|
|
147
|
-
await runWorkflow(SIMPLE_WORKFLOW, BASE_CTX, {}, {});
|
|
148
|
-
// Two agent phases: architect and executor
|
|
149
|
-
expect(mockExecuteAgent).toHaveBeenCalledTimes(2);
|
|
150
|
-
// The prompts contain the template path (mocked as TEMPLATE:path)
|
|
151
|
-
expect(calls[0]).toContain("prompts/architect.md");
|
|
152
|
-
expect(calls[1]).toContain("prompts/executor.md");
|
|
153
|
-
});
|
|
154
|
-
it("returns success=true when all phases pass", async () => {
|
|
155
|
-
mockExecuteAgent.mockResolvedValue(makeSuccessResult());
|
|
156
|
-
const result = await runWorkflow(SIMPLE_WORKFLOW, BASE_CTX, {}, {});
|
|
157
|
-
expect(result.success).toBe(true);
|
|
158
|
-
});
|
|
159
|
-
it("posts a phase's own output to postComment via on_success", async () => {
|
|
160
|
-
// Regression: a phase referencing its OWN output_var in on_success (e.g.
|
|
161
|
-
// the answer workflow's `on_success: "{{answerResult}}"`, which delivers the
|
|
162
|
-
// answer to a Slack thread / issue comment) must render to the real output,
|
|
163
|
-
// not an empty string — outputVars are now injected into the done-step
|
|
164
|
-
// render context rather than only after execute() returns.
|
|
165
|
-
mockExecuteAgent.mockResolvedValue(makeSuccessResult("the full answer text"));
|
|
166
|
-
const comments = [];
|
|
167
|
-
const def = {
|
|
168
|
-
kind: "agent",
|
|
169
|
-
name: "answer",
|
|
170
|
-
phases: [
|
|
171
|
-
{ name: "phase_0", type: "context" },
|
|
172
|
-
{
|
|
173
|
-
name: "answer",
|
|
174
|
-
type: "agent",
|
|
175
|
-
prompt: "prompts/answer.md",
|
|
176
|
-
output_var: "answerResult",
|
|
177
|
-
messages: { on_success: "{{answerResult}}" },
|
|
178
|
-
},
|
|
179
|
-
],
|
|
180
|
-
};
|
|
181
|
-
await runWorkflow(def, BASE_CTX, {}, {
|
|
182
|
-
postComment: async (msg) => { comments.push(msg); },
|
|
183
|
-
});
|
|
184
|
-
expect(comments).toContain("the full answer text");
|
|
185
|
-
});
|
|
186
|
-
it("returns success=false and stops on phase failure", async () => {
|
|
187
|
-
mockExecuteAgent
|
|
188
|
-
.mockResolvedValueOnce(makeSuccessResult("architect done"))
|
|
189
|
-
.mockResolvedValueOnce(makeFailResult("executor exploded"));
|
|
190
|
-
const result = await runWorkflow(SIMPLE_WORKFLOW, BASE_CTX, {}, {});
|
|
191
|
-
expect(result.success).toBe(false);
|
|
192
|
-
// Only two phases executed (phase_0 + architect + executor)
|
|
193
|
-
// phase_0 = context, architect = success, executor = fail → stops
|
|
194
|
-
const names = result.phases.map((p) => p.phase);
|
|
195
|
-
expect(names).toContain("architect");
|
|
196
|
-
expect(names).toContain("executor");
|
|
197
|
-
expect(names).not.toContain("pr");
|
|
198
|
-
});
|
|
199
|
-
});
|
|
200
|
-
describe("runWorkflow — guardrails on_output rules", () => {
|
|
201
|
-
beforeEach(() => {
|
|
202
|
-
vi.clearAllMocks();
|
|
203
|
-
});
|
|
204
|
-
it("fails workflow when guardrails output contains BLOCKED", async () => {
|
|
205
|
-
mockExecuteAgent.mockResolvedValue(makeSuccessResult("BLOCKED — no test framework"));
|
|
206
|
-
const comments = [];
|
|
207
|
-
const result = await runWorkflow(WORKFLOW_WITH_GUARDRAILS, BASE_CTX, {}, { postComment: async (msg) => { comments.push(msg); } });
|
|
208
|
-
expect(result.success).toBe(false);
|
|
209
|
-
expect(comments.some((c) => c.includes("BLOCKED"))).toBe(true);
|
|
210
|
-
// architect's agent must not have run — only guardrails was dispatched.
|
|
211
|
-
expect(mockExecuteAgent).toHaveBeenCalledTimes(1);
|
|
212
|
-
// Under the unified scheduler a failed phase cascades downstream as a skip,
|
|
213
|
-
// recorded in phases[] rather than omitted.
|
|
214
|
-
const architect = result.phases.find((p) => p.phase === "architect");
|
|
215
|
-
expect(architect?.output).toContain("Skipped");
|
|
216
|
-
});
|
|
217
|
-
it("bypasses BLOCKED for bootstrap tasks (by label)", async () => {
|
|
218
|
-
mockExecuteAgent.mockResolvedValue(makeSuccessResult("BLOCKED — no test framework"));
|
|
219
|
-
mockExecuteAgent.mockResolvedValue(makeSuccessResult("READY"));
|
|
220
|
-
const ctx = { ...BASE_CTX, issueLabels: ["lastlight:bootstrap"] };
|
|
221
|
-
// First call = guardrails returns BLOCKED, second = architect succeeds
|
|
222
|
-
mockExecuteAgent
|
|
223
|
-
.mockResolvedValueOnce(makeSuccessResult("BLOCKED"))
|
|
224
|
-
.mockResolvedValueOnce(makeSuccessResult("architect plan"));
|
|
225
|
-
const result = await runWorkflow(WORKFLOW_WITH_GUARDRAILS, ctx, {}, {});
|
|
226
|
-
// Even though guardrails returned BLOCKED, we bypass it because of the label
|
|
227
|
-
expect(result.phases.map((p) => p.phase)).toContain("architect");
|
|
228
|
-
});
|
|
229
|
-
it("bypasses BLOCKED when unless_title_matches regex hits", async () => {
|
|
230
|
-
const workflow = {
|
|
231
|
-
kind: "agent",
|
|
232
|
-
name: "title-bypass",
|
|
233
|
-
phases: [
|
|
234
|
-
{ name: "phase_0", type: "context" },
|
|
235
|
-
{
|
|
236
|
-
name: "guardrails",
|
|
237
|
-
type: "agent",
|
|
238
|
-
prompt: "prompts/guardrails.md",
|
|
239
|
-
on_output: {
|
|
240
|
-
contains_BLOCKED: {
|
|
241
|
-
action: "fail",
|
|
242
|
-
message: "blocked",
|
|
243
|
-
unless_title_matches: "^guardrails:",
|
|
244
|
-
},
|
|
245
|
-
},
|
|
246
|
-
},
|
|
247
|
-
{ name: "architect", type: "agent", prompt: "prompts/architect.md" },
|
|
248
|
-
],
|
|
249
|
-
};
|
|
250
|
-
const ctx = { ...BASE_CTX, issueTitle: "guardrails: add test framework" };
|
|
251
|
-
mockExecuteAgent
|
|
252
|
-
.mockResolvedValueOnce(makeSuccessResult("BLOCKED"))
|
|
253
|
-
.mockResolvedValueOnce(makeSuccessResult("architect plan"));
|
|
254
|
-
const result = await runWorkflow(workflow, ctx, {}, {});
|
|
255
|
-
expect(result.phases.map((p) => p.phase)).toContain("architect");
|
|
256
|
-
});
|
|
257
|
-
it("continues normally when guardrails returns READY", async () => {
|
|
258
|
-
mockExecuteAgent
|
|
259
|
-
.mockResolvedValueOnce(makeSuccessResult("READY — all guardrails pass"))
|
|
260
|
-
.mockResolvedValueOnce(makeSuccessResult("architect plan"));
|
|
261
|
-
const result = await runWorkflow(WORKFLOW_WITH_GUARDRAILS, BASE_CTX, {}, {});
|
|
262
|
-
expect(result.success).toBe(true);
|
|
263
|
-
expect(result.phases.map((p) => p.phase)).toContain("architect");
|
|
264
|
-
});
|
|
265
|
-
});
|
|
266
|
-
describe("runWorkflow — reviewer loop", () => {
|
|
267
|
-
beforeEach(() => {
|
|
268
|
-
vi.clearAllMocks();
|
|
269
|
-
});
|
|
270
|
-
it("approves on first review — no fix loop", async () => {
|
|
271
|
-
mockExecuteAgent
|
|
272
|
-
.mockResolvedValueOnce(makeSuccessResult("executor done")) // executor
|
|
273
|
-
.mockResolvedValueOnce(makeSuccessResult("VERDICT: APPROVED\nLooks great!")) // reviewer
|
|
274
|
-
.mockResolvedValueOnce(makeSuccessResult("PR #7 created")); // pr
|
|
275
|
-
const result = await runWorkflow(WORKFLOW_WITH_REVIEWER_LOOP, BASE_CTX, {}, {});
|
|
276
|
-
expect(result.success).toBe(true);
|
|
277
|
-
// executor + reviewer + pr = 3 agent calls
|
|
278
|
-
expect(mockExecuteAgent).toHaveBeenCalledTimes(3);
|
|
279
|
-
expect(result.prNumber).toBe(7);
|
|
280
|
-
});
|
|
281
|
-
it("runs one fix cycle on REQUEST_CHANGES then APPROVED", async () => {
|
|
282
|
-
mockExecuteAgent
|
|
283
|
-
.mockResolvedValueOnce(makeSuccessResult("executor done")) // executor
|
|
284
|
-
.mockResolvedValueOnce(makeSuccessResult("VERDICT: REQUEST_CHANGES\nFix the bug")) // reviewer cycle 1
|
|
285
|
-
.mockResolvedValueOnce(makeSuccessResult("fixed")) // fix_loop_1
|
|
286
|
-
.mockResolvedValueOnce(makeSuccessResult("VERDICT: APPROVED\nAll fixed")) // re-review
|
|
287
|
-
.mockResolvedValueOnce(makeSuccessResult("PR #8 created")); // pr
|
|
288
|
-
const phases = [];
|
|
289
|
-
const result = await runWorkflow(WORKFLOW_WITH_REVIEWER_LOOP, BASE_CTX, {}, {
|
|
290
|
-
onPhaseStart: async (p) => { phases.push(p); },
|
|
291
|
-
});
|
|
292
|
-
expect(result.success).toBe(true);
|
|
293
|
-
expect(phases).toContain("reviewer_fix_1");
|
|
294
|
-
expect(result.prNumber).toBe(8);
|
|
295
|
-
});
|
|
296
|
-
it("stops after max_cycles when reviewer keeps requesting changes", async () => {
|
|
297
|
-
mockExecuteAgent
|
|
298
|
-
.mockResolvedValueOnce(makeSuccessResult("executor done")) // executor
|
|
299
|
-
.mockResolvedValueOnce(makeSuccessResult("VERDICT: REQUEST_CHANGES")) // reviewer cycle 1
|
|
300
|
-
.mockResolvedValueOnce(makeSuccessResult("fixed 1")) // fix_loop_1
|
|
301
|
-
.mockResolvedValueOnce(makeSuccessResult("VERDICT: REQUEST_CHANGES")) // re-review cycle 2
|
|
302
|
-
.mockResolvedValueOnce(makeSuccessResult("fixed 2")) // fix_loop_2
|
|
303
|
-
.mockResolvedValueOnce(makeSuccessResult("VERDICT: REQUEST_CHANGES")) // re-review cycle 3 (max hit)
|
|
304
|
-
.mockResolvedValueOnce(makeSuccessResult("PR #9 created")); // pr
|
|
305
|
-
const result = await runWorkflow(WORKFLOW_WITH_REVIEWER_LOOP, BASE_CTX, {}, {});
|
|
306
|
-
// Should proceed to PR after max cycles
|
|
307
|
-
expect(result.prNumber).toBe(9);
|
|
308
|
-
});
|
|
309
|
-
it("uses fallback verdict detection when VERDICT: marker is missing", async () => {
|
|
310
|
-
mockExecuteAgent
|
|
311
|
-
.mockResolvedValueOnce(makeSuccessResult("executor done"))
|
|
312
|
-
.mockResolvedValueOnce(makeSuccessResult("APPROVED — code looks fine")) // no marker
|
|
313
|
-
.mockResolvedValueOnce(makeSuccessResult("PR #10 created"));
|
|
314
|
-
const result = await runWorkflow(WORKFLOW_WITH_REVIEWER_LOOP, BASE_CTX, {}, {});
|
|
315
|
-
expect(result.success).toBe(true);
|
|
316
|
-
expect(result.prNumber).toBe(10);
|
|
317
|
-
});
|
|
318
|
-
});
|
|
319
|
-
const WORKFLOW_WITH_APPROVAL_GATE = {
|
|
320
|
-
kind: "agent",
|
|
321
|
-
name: "gated",
|
|
322
|
-
phases: [
|
|
323
|
-
{ name: "phase_0", type: "context" },
|
|
324
|
-
{ name: "architect", type: "agent", prompt: "prompts/architect.md", approval_gate: "post_architect" },
|
|
325
|
-
{ name: "executor", type: "agent", prompt: "prompts/executor.md" },
|
|
326
|
-
{ name: "pr", type: "agent", prompt: "prompts/pr.md", on_success: { set_phase: "complete" } },
|
|
327
|
-
],
|
|
328
|
-
};
|
|
329
|
-
/**
|
|
330
|
-
* Minimal StateDb mock providing the methods used by runWorkflow.
|
|
331
|
-
* currentPhase controls what getWorkflowRun returns (simulating DB state after
|
|
332
|
-
* the orchestrator updates it prior to calling runWorkflow).
|
|
333
|
-
*/
|
|
334
|
-
function makeMockDb(currentPhase = "phase_0") {
|
|
335
|
-
let phase = currentPhase;
|
|
336
|
-
// Mirror prod: every `updateWorkflowPhase` call appends to phase_history.
|
|
337
|
-
// Tests that pass a non-"phase_0" `currentPhase` are simulating a resumed
|
|
338
|
-
// run, so seed history with one entry so the runner's resume detection
|
|
339
|
-
// kicks in (it requires phaseHistory.length > 0).
|
|
340
|
-
const phaseHistory = currentPhase !== "phase_0"
|
|
341
|
-
? [{ phase: currentPhase, timestamp: new Date().toISOString(), success: true }]
|
|
342
|
-
: [];
|
|
343
|
-
const appendPhase = vi.fn((_id, newPhase, entry) => {
|
|
344
|
-
phase = newPhase;
|
|
345
|
-
if (entry)
|
|
346
|
-
phaseHistory.push(entry);
|
|
347
|
-
});
|
|
348
|
-
return {
|
|
349
|
-
runs: {
|
|
350
|
-
getRun: vi.fn(() => ({ currentPhase: phase, phaseHistory, status: "running" })),
|
|
351
|
-
appendPhase,
|
|
352
|
-
pauseForApproval: vi.fn(),
|
|
353
|
-
mergeScratch: vi.fn(),
|
|
354
|
-
finishRun: vi.fn(),
|
|
355
|
-
setRunning: vi.fn(),
|
|
356
|
-
setPaused: vi.fn(),
|
|
357
|
-
},
|
|
358
|
-
approvals: {
|
|
359
|
-
create: vi.fn(),
|
|
360
|
-
getPendingForWorkflow: vi.fn(() => null),
|
|
361
|
-
},
|
|
362
|
-
executions: {
|
|
363
|
-
shouldRunPhase: vi.fn(() => "run"),
|
|
364
|
-
recordStart: vi.fn(),
|
|
365
|
-
recordFinish: vi.fn(),
|
|
366
|
-
recordSkippedPhase: vi.fn(),
|
|
367
|
-
recordOutputText: vi.fn(),
|
|
368
|
-
getExecutionOutput: vi.fn(() => null),
|
|
369
|
-
getPhaseOutput: vi.fn(() => null),
|
|
370
|
-
recordSessionId: vi.fn(),
|
|
371
|
-
markStaleAsFailed: vi.fn(),
|
|
372
|
-
markLatestAsFailed: vi.fn(),
|
|
373
|
-
},
|
|
374
|
-
};
|
|
375
|
-
}
|
|
376
|
-
describe("runWorkflow — requires_sandbox gate", () => {
|
|
377
|
-
const GATED_WORKFLOW = {
|
|
378
|
-
kind: "agent",
|
|
379
|
-
name: "gated",
|
|
380
|
-
phases: [
|
|
381
|
-
{ name: "phase_0", type: "context" },
|
|
382
|
-
{
|
|
383
|
-
name: "demo",
|
|
384
|
-
type: "agent",
|
|
385
|
-
prompt: "prompts/demo.md",
|
|
386
|
-
requires_sandbox: "docker",
|
|
387
|
-
messages: { on_skipped_done: "Demo skipped — no docker sandbox here." },
|
|
388
|
-
},
|
|
389
|
-
],
|
|
390
|
-
};
|
|
391
|
-
beforeEach(() => {
|
|
392
|
-
vi.clearAllMocks();
|
|
393
|
-
});
|
|
394
|
-
it("skips a docker-gated phase as a non-failing skip on a non-docker backend", async () => {
|
|
395
|
-
const db = makeMockDb();
|
|
396
|
-
// No `sandbox` on the config → active backend resolves to gondolin.
|
|
397
|
-
const result = await runWorkflow(GATED_WORKFLOW, BASE_CTX, {}, {}, db);
|
|
398
|
-
// The gated phase never ran...
|
|
399
|
-
expect(mockExecuteAgent).not.toHaveBeenCalled();
|
|
400
|
-
// ...the run did not fail...
|
|
401
|
-
expect(result.success).toBe(true);
|
|
402
|
-
// ...the phase was recorded as a non-failing skip...
|
|
403
|
-
const demo = result.phases.find((p) => p.phase === "demo");
|
|
404
|
-
expect(demo?.success).toBe(true);
|
|
405
|
-
// ...and it landed in the executions ledger as a skip.
|
|
406
|
-
expect(db.executions.recordSkippedPhase).toHaveBeenCalledWith("gated:demo", expect.any(String), undefined, expect.any(String));
|
|
407
|
-
});
|
|
408
|
-
it("runs a docker-gated phase when the active backend is docker", async () => {
|
|
409
|
-
mockExecuteAgent.mockResolvedValue(makeSuccessResult());
|
|
410
|
-
const result = await runWorkflow(GATED_WORKFLOW, BASE_CTX, { sandbox: "docker" }, {});
|
|
411
|
-
expect(mockExecuteAgent).toHaveBeenCalledTimes(1);
|
|
412
|
-
expect(result.success).toBe(true);
|
|
413
|
-
});
|
|
414
|
-
// A `sandbox_image: qa` phase needs the browser-QA image built on the host.
|
|
415
|
-
// The mocked child_process has no `execFileSync`, so `qaImageAvailable()`
|
|
416
|
-
// throws → false: the phase must skip even on the docker backend rather than
|
|
417
|
-
// try (and fail) to spawn a sandbox from a non-existent image.
|
|
418
|
-
const QA_WORKFLOW = {
|
|
419
|
-
kind: "agent",
|
|
420
|
-
name: "qa",
|
|
421
|
-
phases: [
|
|
422
|
-
{ name: "phase_0", type: "context" },
|
|
423
|
-
{
|
|
424
|
-
name: "browser",
|
|
425
|
-
type: "agent",
|
|
426
|
-
prompt: "prompts/browser.md",
|
|
427
|
-
requires_sandbox: "docker",
|
|
428
|
-
sandbox_image: "qa",
|
|
429
|
-
messages: { on_skipped_done: "Browser QA skipped — image not built." },
|
|
430
|
-
},
|
|
431
|
-
],
|
|
432
|
-
};
|
|
433
|
-
it("skips a sandbox_image:qa phase on docker when the QA image isn't built", async () => {
|
|
434
|
-
const db = makeMockDb();
|
|
435
|
-
const result = await runWorkflow(QA_WORKFLOW, BASE_CTX, { sandbox: "docker" }, {}, db);
|
|
436
|
-
expect(mockExecuteAgent).not.toHaveBeenCalled();
|
|
437
|
-
expect(result.success).toBe(true);
|
|
438
|
-
const browser = result.phases.find((p) => p.phase === "browser");
|
|
439
|
-
expect(browser?.success).toBe(true);
|
|
440
|
-
expect(browser?.output).toMatch(/lastlight-sandbox-qa:latest/);
|
|
441
|
-
expect(db.executions.recordSkippedPhase).toHaveBeenCalledWith("qa:browser", expect.any(String), undefined, expect.any(String));
|
|
442
|
-
});
|
|
443
|
-
});
|
|
444
|
-
describe("runWorkflow — final_message + synthesize (verify/qa-test shape)", () => {
|
|
445
|
-
beforeEach(() => {
|
|
446
|
-
vi.clearAllMocks();
|
|
447
|
-
});
|
|
448
|
-
// Mirrors verify.yaml: a gated browser phase that skips off-docker, and a
|
|
449
|
-
// `synthesize` phase that depends ONLY on the text phase so it still runs
|
|
450
|
-
// (after the gated phase, by declaration order) and folds the outputs into
|
|
451
|
-
// `final_message` → the single comment's footer.
|
|
452
|
-
const VERIFY_LIKE = {
|
|
453
|
-
kind: "verify",
|
|
454
|
-
name: "verify",
|
|
455
|
-
status_checklist: true,
|
|
456
|
-
final_message: "{{synthesisResult}}",
|
|
457
|
-
phases: [
|
|
458
|
-
{ name: "phase_0", type: "context", depends_on: [] },
|
|
459
|
-
{
|
|
460
|
-
name: "verify",
|
|
461
|
-
type: "agent",
|
|
462
|
-
prompt: "prompts/verify.md",
|
|
463
|
-
depends_on: ["phase_0"],
|
|
464
|
-
output_var: "verifyResult",
|
|
465
|
-
messages: { on_success: "Text verification complete." },
|
|
466
|
-
},
|
|
467
|
-
{
|
|
468
|
-
name: "verify_browser",
|
|
469
|
-
type: "agent",
|
|
470
|
-
prompt: "prompts/verify-browser.md",
|
|
471
|
-
depends_on: ["verify"],
|
|
472
|
-
requires_sandbox: "docker",
|
|
473
|
-
sandbox_image: "qa",
|
|
474
|
-
output_var: "verifyBrowserResult",
|
|
475
|
-
messages: { on_skipped_done: "Browser QA unavailable on this host." },
|
|
476
|
-
},
|
|
477
|
-
{
|
|
478
|
-
name: "synthesize",
|
|
479
|
-
type: "agent",
|
|
480
|
-
prompt: "prompts/verify-synth.md",
|
|
481
|
-
depends_on: ["verify"],
|
|
482
|
-
output_var: "synthesisResult",
|
|
483
|
-
messages: { on_success: "Verdict posted below." },
|
|
484
|
-
},
|
|
485
|
-
],
|
|
486
|
-
};
|
|
487
|
-
function fakeReporter(sink) {
|
|
488
|
-
return {
|
|
489
|
-
start: async (_m) => { },
|
|
490
|
-
step: async (k, s) => { sink.steps.push([k, s]); },
|
|
491
|
-
insertStep: async (_st) => { },
|
|
492
|
-
note: async () => { },
|
|
493
|
-
footer: async (m) => { sink.footer = m; },
|
|
494
|
-
noteTerminal: async () => { },
|
|
495
|
-
};
|
|
496
|
-
}
|
|
497
|
-
it("synthesizes into the checklist footer (single comment) when the browser phase is gated out", async () => {
|
|
498
|
-
// gondolin backend (no `sandbox`) → verify_browser skips. Only verify +
|
|
499
|
-
// synthesize call the agent.
|
|
500
|
-
mockExecuteAgent
|
|
501
|
-
.mockResolvedValueOnce(makeSuccessResult("text verdict body")) // verify
|
|
502
|
-
.mockResolvedValueOnce(makeSuccessResult("## CONFIRMED\nfinal verdict")); // synthesize
|
|
503
|
-
const sink = { steps: [] };
|
|
504
|
-
const comments = [];
|
|
505
|
-
const result = await runWorkflow(VERIFY_LIKE, BASE_CTX, {}, {
|
|
506
|
-
reporter: fakeReporter(sink),
|
|
507
|
-
postComment: async (m) => { comments.push(m); },
|
|
508
|
-
}, makeMockDb());
|
|
509
|
-
expect(result.success).toBe(true);
|
|
510
|
-
// verify + synthesize ran; verify_browser was skipped (no docker).
|
|
511
|
-
expect(mockExecuteAgent).toHaveBeenCalledTimes(2);
|
|
512
|
-
expect(result.phases.map((p) => p.phase)).toContain("synthesize");
|
|
513
|
-
// The synthesized verdict lands in the footer — NOT as a standalone comment.
|
|
514
|
-
expect(sink.footer).toBe("## CONFIRMED\nfinal verdict");
|
|
515
|
-
expect(comments).toHaveLength(0);
|
|
516
|
-
// Per-phase progress went to checklist steps, not comments.
|
|
517
|
-
expect(sink.steps).toContainEqual(["verify", "done"]);
|
|
518
|
-
expect(sink.steps).toContainEqual(["synthesize", "done"]);
|
|
519
|
-
});
|
|
520
|
-
it("renders final_message to a single postComment in the legacy (no-reporter) path", async () => {
|
|
521
|
-
mockExecuteAgent.mockResolvedValue(makeSuccessResult("the synthesized verdict"));
|
|
522
|
-
const def = {
|
|
523
|
-
kind: "agent",
|
|
524
|
-
name: "fin",
|
|
525
|
-
final_message: "{{r}}",
|
|
526
|
-
phases: [
|
|
527
|
-
{ name: "phase_0", type: "context" },
|
|
528
|
-
{ name: "a", type: "agent", prompt: "prompts/a.md", output_var: "r" },
|
|
529
|
-
],
|
|
530
|
-
};
|
|
531
|
-
const comments = [];
|
|
532
|
-
const result = await runWorkflow(def, BASE_CTX, {}, {
|
|
533
|
-
postComment: async (m) => { comments.push(m); },
|
|
534
|
-
});
|
|
535
|
-
expect(result.success).toBe(true);
|
|
536
|
-
expect(comments).toContain("the synthesized verdict");
|
|
537
|
-
});
|
|
538
|
-
});
|
|
539
|
-
describe("runWorkflow — approval gate", () => {
|
|
540
|
-
beforeEach(() => {
|
|
541
|
-
vi.clearAllMocks();
|
|
542
|
-
});
|
|
543
|
-
it("pauses at post_architect gate and does not run executor", async () => {
|
|
544
|
-
mockExecuteAgent.mockResolvedValueOnce(makeSuccessResult("architect plan done"));
|
|
545
|
-
const db = makeMockDb("phase_0");
|
|
546
|
-
const approvalConfig = { post_architect: true };
|
|
547
|
-
const result = await runWorkflow(WORKFLOW_WITH_APPROVAL_GATE, BASE_CTX, {}, {}, db, undefined, approvalConfig, "wf-gate-1");
|
|
548
|
-
expect(result.paused).toBe(true);
|
|
549
|
-
expect(result.success).toBe(true);
|
|
550
|
-
// Only architect ran — executor and pr were not reached
|
|
551
|
-
expect(mockExecuteAgent).toHaveBeenCalledTimes(1);
|
|
552
|
-
expect(result.phases.map((p) => p.phase)).not.toContain("executor");
|
|
553
|
-
});
|
|
554
|
-
it("resumes past an approved gate via the executions ledger (architect already done)", async () => {
|
|
555
|
-
// Ledger-driven resume: architect's execution row is success=1 ("done"),
|
|
556
|
-
// so runPhase skips it and the gate isn't re-hit — executor + pr run.
|
|
557
|
-
const db = makeMockDb("architect");
|
|
558
|
-
vi.mocked(db.executions.shouldRunPhase).mockImplementation((skill) => skill === "gated:architect" ? "done" : "run");
|
|
559
|
-
const approvalConfig = { post_architect: true };
|
|
560
|
-
mockExecuteAgent
|
|
561
|
-
.mockResolvedValueOnce(makeSuccessResult("executor done"))
|
|
562
|
-
.mockResolvedValueOnce(makeSuccessResult("PR #5 created"));
|
|
563
|
-
const result = await runWorkflow(WORKFLOW_WITH_APPROVAL_GATE, BASE_CTX, {}, {}, db, undefined, approvalConfig, "wf-gate-1");
|
|
564
|
-
// Not paused — architect was deduped (done), executor and pr ran.
|
|
565
|
-
expect(result.paused).toBeUndefined();
|
|
566
|
-
expect(result.success).toBe(true);
|
|
567
|
-
expect(result.prNumber).toBe(5);
|
|
568
|
-
expect(mockExecuteAgent).toHaveBeenCalledTimes(2);
|
|
569
|
-
});
|
|
570
|
-
it("pauses at a custom-named gate when that gate is enabled", async () => {
|
|
571
|
-
const workflow = {
|
|
572
|
-
kind: "agent",
|
|
573
|
-
name: "custom-gate",
|
|
574
|
-
phases: [
|
|
575
|
-
{ name: "phase_0", type: "context" },
|
|
576
|
-
{ name: "plan", type: "agent", prompt: "prompts/plan.md", approval_gate: "post_plan" },
|
|
577
|
-
{ name: "build", type: "agent", prompt: "prompts/build.md" },
|
|
578
|
-
],
|
|
579
|
-
};
|
|
580
|
-
mockExecuteAgent.mockResolvedValueOnce(makeSuccessResult("plan done"));
|
|
581
|
-
const db = makeMockDb("phase_0");
|
|
582
|
-
const result = await runWorkflow(workflow, BASE_CTX, {}, {}, db, undefined, { post_plan: true }, "wf-custom-1");
|
|
583
|
-
expect(result.paused).toBe(true);
|
|
584
|
-
expect(mockExecuteAgent).toHaveBeenCalledTimes(1);
|
|
585
|
-
});
|
|
586
|
-
});
|
|
587
|
-
describe("runWorkflow — callbacks", () => {
|
|
588
|
-
beforeEach(() => {
|
|
589
|
-
vi.clearAllMocks();
|
|
590
|
-
});
|
|
591
|
-
it("calls onPhaseStart and onPhaseEnd for each phase", async () => {
|
|
592
|
-
mockExecuteAgent.mockResolvedValue(makeSuccessResult());
|
|
593
|
-
const started = [];
|
|
594
|
-
const ended = [];
|
|
595
|
-
const callbacks = {
|
|
596
|
-
onPhaseStart: async (p) => { started.push(p); },
|
|
597
|
-
onPhaseEnd: async (p) => { ended.push(p); },
|
|
598
|
-
};
|
|
599
|
-
await runWorkflow(SIMPLE_WORKFLOW, BASE_CTX, {}, callbacks);
|
|
600
|
-
expect(started).toContain("phase_0");
|
|
601
|
-
expect(started).toContain("architect");
|
|
602
|
-
expect(started).toContain("executor");
|
|
603
|
-
expect(ended).toContain("architect");
|
|
604
|
-
expect(ended).toContain("executor");
|
|
605
|
-
});
|
|
606
|
-
it("renders phase.messages.on_failure through the template engine", async () => {
|
|
607
|
-
const workflow = {
|
|
608
|
-
kind: "agent",
|
|
609
|
-
name: "fail-msg",
|
|
610
|
-
phases: [
|
|
611
|
-
{ name: "phase_0", type: "context" },
|
|
612
|
-
{ name: "architect", type: "agent", prompt: "prompts/architect.md" },
|
|
613
|
-
{
|
|
614
|
-
name: "executor",
|
|
615
|
-
type: "agent",
|
|
616
|
-
prompt: "prompts/executor.md",
|
|
617
|
-
messages: { on_failure: "**{{issueTitle}}** failed — aborting" },
|
|
618
|
-
},
|
|
619
|
-
],
|
|
620
|
-
};
|
|
621
|
-
mockExecuteAgent
|
|
622
|
-
.mockResolvedValueOnce(makeSuccessResult())
|
|
623
|
-
.mockResolvedValueOnce(makeFailResult("connection timeout"));
|
|
624
|
-
const comments = [];
|
|
625
|
-
await runWorkflow(workflow, BASE_CTX, {}, {
|
|
626
|
-
postComment: async (msg) => { comments.push(msg); },
|
|
627
|
-
});
|
|
628
|
-
expect(comments).toContain("**Add Rate Limiter** failed — aborting");
|
|
629
|
-
});
|
|
630
|
-
it("silently skips notification when no on_failure template is set", async () => {
|
|
631
|
-
mockExecuteAgent
|
|
632
|
-
.mockResolvedValueOnce(makeSuccessResult())
|
|
633
|
-
.mockResolvedValueOnce(makeFailResult("connection timeout"));
|
|
634
|
-
const comments = [];
|
|
635
|
-
await runWorkflow(SIMPLE_WORKFLOW, BASE_CTX, {}, {
|
|
636
|
-
postComment: async (msg) => { comments.push(msg); },
|
|
637
|
-
});
|
|
638
|
-
expect(comments).toEqual([]);
|
|
639
|
-
});
|
|
640
|
-
});
|
|
641
|
-
// until_bash now runs in the sandbox via executeCommand; exit code maps to
|
|
642
|
-
// ExecutionResult.success (exit 0 → success: true).
|
|
643
|
-
const cmdOk = () => ({ success: true, output: "", turns: 0, durationMs: 1 });
|
|
644
|
-
const cmdFail = () => ({ success: false, output: "", error: "command exited 1", turns: 0, durationMs: 1 });
|
|
645
|
-
const WORKFLOW_WITH_GENERIC_LOOP = {
|
|
646
|
-
kind: "agent",
|
|
647
|
-
name: "loop-test",
|
|
648
|
-
phases: [
|
|
649
|
-
{ name: "phase_0", type: "context" },
|
|
650
|
-
{
|
|
651
|
-
name: "implement",
|
|
652
|
-
type: "agent",
|
|
653
|
-
prompt: "prompts/implement.md",
|
|
654
|
-
generic_loop: {
|
|
655
|
-
max_iterations: 5,
|
|
656
|
-
until_bash: "npm test",
|
|
657
|
-
interactive: false,
|
|
658
|
-
fresh_context: false,
|
|
659
|
-
},
|
|
660
|
-
},
|
|
661
|
-
],
|
|
662
|
-
};
|
|
663
|
-
describe("runWorkflow — generic loop node", () => {
|
|
664
|
-
beforeEach(() => {
|
|
665
|
-
vi.clearAllMocks();
|
|
666
|
-
});
|
|
667
|
-
it("completes on first iteration when until_bash exits 0", async () => {
|
|
668
|
-
mockExecuteAgent.mockResolvedValueOnce(makeSuccessResult("tests pass"));
|
|
669
|
-
mockExecuteCommand.mockResolvedValueOnce(cmdOk()); // exit 0
|
|
670
|
-
const started = [];
|
|
671
|
-
const result = await runWorkflow(WORKFLOW_WITH_GENERIC_LOOP, BASE_CTX, {}, {
|
|
672
|
-
onPhaseStart: async (p) => { started.push(p); },
|
|
673
|
-
});
|
|
674
|
-
expect(result.success).toBe(true);
|
|
675
|
-
expect(mockExecuteAgent).toHaveBeenCalledTimes(1);
|
|
676
|
-
expect(started).toContain("implement_iter_1");
|
|
677
|
-
expect(started).not.toContain("implement_iter_2");
|
|
678
|
-
});
|
|
679
|
-
it("iterates when until_bash exits non-zero then completes on exit 0", async () => {
|
|
680
|
-
mockExecuteAgent
|
|
681
|
-
.mockResolvedValueOnce(makeSuccessResult("attempt 1"))
|
|
682
|
-
.mockResolvedValueOnce(makeSuccessResult("attempt 2"));
|
|
683
|
-
mockExecuteCommand
|
|
684
|
-
.mockResolvedValueOnce(cmdFail()) // iteration 1 fails
|
|
685
|
-
.mockResolvedValueOnce(cmdOk()); // iteration 2 passes
|
|
686
|
-
const started = [];
|
|
687
|
-
const result = await runWorkflow(WORKFLOW_WITH_GENERIC_LOOP, BASE_CTX, {}, {
|
|
688
|
-
onPhaseStart: async (p) => { started.push(p); },
|
|
689
|
-
});
|
|
690
|
-
expect(result.success).toBe(true);
|
|
691
|
-
expect(mockExecuteAgent).toHaveBeenCalledTimes(2);
|
|
692
|
-
expect(started).toContain("implement_iter_1");
|
|
693
|
-
expect(started).toContain("implement_iter_2");
|
|
694
|
-
expect(started).not.toContain("implement_iter_3");
|
|
695
|
-
});
|
|
696
|
-
it("stops at max_iterations when condition is never met", async () => {
|
|
697
|
-
const workflow = {
|
|
698
|
-
kind: "agent",
|
|
699
|
-
name: "max-iter-msg",
|
|
700
|
-
phases: [
|
|
701
|
-
{ name: "phase_0", type: "context" },
|
|
702
|
-
{
|
|
703
|
-
name: "implement",
|
|
704
|
-
type: "agent",
|
|
705
|
-
prompt: "prompts/implement.md",
|
|
706
|
-
messages: {
|
|
707
|
-
on_failure: "{{phase}} stopped at {{maxIterations}} max iterations",
|
|
708
|
-
},
|
|
709
|
-
generic_loop: {
|
|
710
|
-
max_iterations: 5,
|
|
711
|
-
until_bash: "npm test",
|
|
712
|
-
interactive: false,
|
|
713
|
-
fresh_context: false,
|
|
714
|
-
},
|
|
715
|
-
},
|
|
716
|
-
],
|
|
717
|
-
};
|
|
718
|
-
mockExecuteCommand.mockResolvedValue(cmdFail());
|
|
719
|
-
mockExecuteAgent.mockResolvedValue(makeSuccessResult("still failing"));
|
|
720
|
-
const comments = [];
|
|
721
|
-
const result = await runWorkflow(workflow, BASE_CTX, {}, {
|
|
722
|
-
postComment: async (msg) => { comments.push(msg); },
|
|
723
|
-
});
|
|
724
|
-
expect(result.success).toBe(true); // workflow itself doesn't fail — just loop exhausted
|
|
725
|
-
expect(mockExecuteAgent).toHaveBeenCalledTimes(5); // max_iterations = 5
|
|
726
|
-
expect(comments.some((c) => c.includes("max iterations"))).toBe(true);
|
|
727
|
-
});
|
|
728
|
-
it("completes immediately when until expression is true on first output", async () => {
|
|
729
|
-
const workflow = {
|
|
730
|
-
kind: "agent",
|
|
731
|
-
name: "expr-loop",
|
|
732
|
-
phases: [
|
|
733
|
-
{ name: "phase_0", type: "context" },
|
|
734
|
-
{
|
|
735
|
-
name: "check",
|
|
736
|
-
type: "agent",
|
|
737
|
-
prompt: "prompts/check.md",
|
|
738
|
-
generic_loop: {
|
|
739
|
-
max_iterations: 3,
|
|
740
|
-
until: "output.contains('PASS')",
|
|
741
|
-
interactive: false,
|
|
742
|
-
fresh_context: false,
|
|
743
|
-
},
|
|
744
|
-
},
|
|
745
|
-
],
|
|
746
|
-
};
|
|
747
|
-
mockExecuteAgent.mockResolvedValueOnce(makeSuccessResult("All tests PASS"));
|
|
748
|
-
const result = await runWorkflow(workflow, BASE_CTX, {}, {});
|
|
749
|
-
expect(result.success).toBe(true);
|
|
750
|
-
expect(mockExecuteAgent).toHaveBeenCalledTimes(1);
|
|
751
|
-
});
|
|
752
|
-
it("iterates when until expression is false then stops when true", async () => {
|
|
753
|
-
const workflow = {
|
|
754
|
-
kind: "agent",
|
|
755
|
-
name: "expr-loop-2",
|
|
756
|
-
phases: [
|
|
757
|
-
{ name: "phase_0", type: "context" },
|
|
758
|
-
{
|
|
759
|
-
name: "refine",
|
|
760
|
-
type: "agent",
|
|
761
|
-
prompt: "prompts/refine.md",
|
|
762
|
-
generic_loop: {
|
|
763
|
-
max_iterations: 4,
|
|
764
|
-
until: "output.contains('DONE')",
|
|
765
|
-
interactive: false,
|
|
766
|
-
fresh_context: false,
|
|
767
|
-
},
|
|
768
|
-
},
|
|
769
|
-
],
|
|
770
|
-
};
|
|
771
|
-
mockExecuteAgent
|
|
772
|
-
.mockResolvedValueOnce(makeSuccessResult("still working"))
|
|
773
|
-
.mockResolvedValueOnce(makeSuccessResult("DONE"));
|
|
774
|
-
const result = await runWorkflow(workflow, BASE_CTX, {}, {});
|
|
775
|
-
expect(result.success).toBe(true);
|
|
776
|
-
expect(mockExecuteAgent).toHaveBeenCalledTimes(2);
|
|
777
|
-
});
|
|
778
|
-
it("fresh_context: true does not pass previousOutput on subsequent iterations", async () => {
|
|
779
|
-
const workflow = {
|
|
780
|
-
kind: "agent",
|
|
781
|
-
name: "fresh-ctx",
|
|
782
|
-
phases: [
|
|
783
|
-
{ name: "phase_0", type: "context" },
|
|
784
|
-
{
|
|
785
|
-
name: "task",
|
|
786
|
-
type: "agent",
|
|
787
|
-
prompt: "prompts/task.md",
|
|
788
|
-
generic_loop: {
|
|
789
|
-
max_iterations: 3,
|
|
790
|
-
until_bash: "npm test",
|
|
791
|
-
interactive: false,
|
|
792
|
-
fresh_context: true,
|
|
793
|
-
},
|
|
794
|
-
},
|
|
795
|
-
],
|
|
796
|
-
};
|
|
797
|
-
const capturedPrompts = [];
|
|
798
|
-
mockExecuteAgent.mockImplementation(async (prompt) => {
|
|
799
|
-
capturedPrompts.push(prompt);
|
|
800
|
-
return makeSuccessResult("iteration output");
|
|
801
|
-
});
|
|
802
|
-
mockExecuteCommand
|
|
803
|
-
.mockResolvedValueOnce(cmdFail())
|
|
804
|
-
.mockResolvedValueOnce(cmdOk());
|
|
805
|
-
await runWorkflow(workflow, BASE_CTX, {}, {});
|
|
806
|
-
// Both prompts should NOT contain the first iteration's output
|
|
807
|
-
// (fresh_context resets previousOutput each time)
|
|
808
|
-
expect(capturedPrompts[1]).not.toContain("iteration output");
|
|
809
|
-
});
|
|
810
|
-
it("fresh_context: false passes previousOutput to subsequent iterations", async () => {
|
|
811
|
-
const workflow = {
|
|
812
|
-
kind: "agent",
|
|
813
|
-
name: "accum-ctx",
|
|
814
|
-
phases: [
|
|
815
|
-
{ name: "phase_0", type: "context" },
|
|
816
|
-
{
|
|
817
|
-
name: "build",
|
|
818
|
-
type: "agent",
|
|
819
|
-
prompt: "prompts/build.md",
|
|
820
|
-
generic_loop: {
|
|
821
|
-
max_iterations: 3,
|
|
822
|
-
until_bash: "npm test",
|
|
823
|
-
interactive: false,
|
|
824
|
-
fresh_context: false,
|
|
825
|
-
},
|
|
826
|
-
},
|
|
827
|
-
],
|
|
828
|
-
};
|
|
829
|
-
// Make the loader return a template that uses {{previousOutput}} so we can
|
|
830
|
-
// verify the context variable is passed through to the rendered prompt
|
|
831
|
-
mockLoadPromptTemplate.mockImplementation(() => "Previous: {{previousOutput}}");
|
|
832
|
-
const capturedPrompts = [];
|
|
833
|
-
mockExecuteAgent.mockImplementation(async (prompt) => {
|
|
834
|
-
capturedPrompts.push(prompt);
|
|
835
|
-
return makeSuccessResult("iteration output ABC");
|
|
836
|
-
});
|
|
837
|
-
mockExecuteCommand
|
|
838
|
-
.mockResolvedValueOnce(cmdFail())
|
|
839
|
-
.mockResolvedValueOnce(cmdOk());
|
|
840
|
-
await runWorkflow(workflow, BASE_CTX, {}, {});
|
|
841
|
-
// Second prompt should include the previous iteration's output
|
|
842
|
-
expect(capturedPrompts[1]).toContain("iteration output ABC");
|
|
843
|
-
});
|
|
844
|
-
it("interactive mode pauses workflow after first iteration", async () => {
|
|
845
|
-
const workflow = {
|
|
846
|
-
kind: "agent",
|
|
847
|
-
name: "interactive-loop",
|
|
848
|
-
phases: [
|
|
849
|
-
{ name: "phase_0", type: "context" },
|
|
850
|
-
{
|
|
851
|
-
name: "explore",
|
|
852
|
-
type: "agent",
|
|
853
|
-
prompt: "prompts/explore.md",
|
|
854
|
-
generic_loop: {
|
|
855
|
-
max_iterations: 3,
|
|
856
|
-
until: "output.contains('FINAL')",
|
|
857
|
-
interactive: true,
|
|
858
|
-
gate_message: "Review iteration output before continuing",
|
|
859
|
-
fresh_context: false,
|
|
860
|
-
},
|
|
861
|
-
},
|
|
862
|
-
],
|
|
863
|
-
};
|
|
864
|
-
// First iteration does not satisfy the until condition
|
|
865
|
-
mockExecuteAgent.mockResolvedValueOnce(makeSuccessResult("progress so far"));
|
|
866
|
-
const db = makeMockDb();
|
|
867
|
-
const comments = [];
|
|
868
|
-
const result = await runWorkflow(workflow, BASE_CTX, {}, { postComment: async (msg) => { comments.push(msg); } }, db, undefined, undefined, "wf-loop-1");
|
|
869
|
-
expect(result.paused).toBe(true);
|
|
870
|
-
expect(result.success).toBe(true);
|
|
871
|
-
expect(db.runs.pauseForApproval).toHaveBeenCalled();
|
|
872
|
-
expect(db.runs.pauseForApproval).toHaveBeenCalled();
|
|
873
|
-
expect(comments.some((c) => c.includes("approval required"))).toBe(true);
|
|
874
|
-
});
|
|
875
|
-
it("fails workflow if an iteration agent call fails", async () => {
|
|
876
|
-
mockExecuteCommand.mockResolvedValue(cmdFail());
|
|
877
|
-
mockExecuteAgent.mockResolvedValueOnce(makeFailResult("oom killed"));
|
|
878
|
-
const comments = [];
|
|
879
|
-
const result = await runWorkflow(WORKFLOW_WITH_GENERIC_LOOP, BASE_CTX, {}, {
|
|
880
|
-
postComment: async (msg) => { comments.push(msg); },
|
|
881
|
-
});
|
|
882
|
-
expect(result.success).toBe(false);
|
|
883
|
-
expect(mockExecuteAgent).toHaveBeenCalledTimes(1);
|
|
884
|
-
});
|
|
885
|
-
it("treats until_bash containing {{ as non-zero exit (template injection guard)", async () => {
|
|
886
|
-
const workflow = {
|
|
887
|
-
kind: "agent",
|
|
888
|
-
name: "injection-guard",
|
|
889
|
-
phases: [
|
|
890
|
-
{ name: "phase_0", type: "context" },
|
|
891
|
-
{
|
|
892
|
-
name: "run",
|
|
893
|
-
type: "agent",
|
|
894
|
-
prompt: "prompts/run.md",
|
|
895
|
-
generic_loop: {
|
|
896
|
-
max_iterations: 1,
|
|
897
|
-
until_bash: "echo {{secret}}",
|
|
898
|
-
interactive: false,
|
|
899
|
-
fresh_context: false,
|
|
900
|
-
},
|
|
901
|
-
},
|
|
902
|
-
],
|
|
903
|
-
};
|
|
904
|
-
mockExecuteAgent.mockResolvedValueOnce(makeSuccessResult("output"));
|
|
905
|
-
// executeCommand should NOT be reached — validateShellCommand throws first.
|
|
906
|
-
mockExecuteCommand.mockResolvedValue(cmdOk());
|
|
907
|
-
const result = await runWorkflow(workflow, BASE_CTX, {}, {});
|
|
908
|
-
// validateShellCommand throws, caught by runUntilBash, conditionMet stays false;
|
|
909
|
-
// loop exhausts max_iterations=1 and the workflow completes (loop exhausted is not failure).
|
|
910
|
-
expect(result.success).toBe(true);
|
|
911
|
-
// executeCommand must NOT have been called with the mustache-containing command.
|
|
912
|
-
expect(mockExecuteCommand).not.toHaveBeenCalled();
|
|
913
|
-
});
|
|
914
|
-
});
|
|
915
|
-
// ── DAG workflow tests ────────────────────────────────────────────────────────
|
|
916
|
-
const PARALLEL_WORKFLOW = {
|
|
917
|
-
kind: "agent",
|
|
918
|
-
name: "parallel-test",
|
|
919
|
-
phases: [
|
|
920
|
-
{ name: "phase_0", type: "context" },
|
|
921
|
-
{ name: "architect", type: "agent", prompt: "prompts/architect.md", depends_on: ["phase_0"] },
|
|
922
|
-
{ name: "executor_a", type: "agent", prompt: "prompts/executor_a.md", depends_on: ["architect"] },
|
|
923
|
-
{ name: "executor_b", type: "agent", prompt: "prompts/executor_b.md", depends_on: ["architect"] },
|
|
924
|
-
{ name: "merge", type: "agent", prompt: "prompts/merge.md", depends_on: ["executor_a", "executor_b"] },
|
|
925
|
-
],
|
|
926
|
-
};
|
|
927
|
-
describe("runWorkflow — DAG parallel execution", () => {
|
|
928
|
-
beforeEach(() => {
|
|
929
|
-
vi.clearAllMocks();
|
|
930
|
-
});
|
|
931
|
-
it("routes to DAG execution when any phase has depends_on", async () => {
|
|
932
|
-
mockExecuteAgent.mockResolvedValue(makeSuccessResult());
|
|
933
|
-
const result = await runWorkflow(PARALLEL_WORKFLOW, BASE_CTX, {}, {});
|
|
934
|
-
expect(result.success).toBe(true);
|
|
935
|
-
// architect + executor_a + executor_b + merge = 4 agent calls
|
|
936
|
-
expect(mockExecuteAgent).toHaveBeenCalledTimes(4);
|
|
937
|
-
});
|
|
938
|
-
it("runs every ready node sequentially in declaration order", async () => {
|
|
939
|
-
mockExecuteAgent.mockResolvedValue(makeSuccessResult("done"));
|
|
940
|
-
const started = [];
|
|
941
|
-
const result = await runWorkflow(PARALLEL_WORKFLOW, BASE_CTX, {}, {
|
|
942
|
-
onPhaseStart: async (p) => { started.push(p); },
|
|
943
|
-
});
|
|
944
|
-
expect(result.success).toBe(true);
|
|
945
|
-
// Sequential, declaration order — the two independent executors no longer
|
|
946
|
-
// run in parallel; the earliest-declared ready node goes first.
|
|
947
|
-
expect(started).toEqual(["phase_0", "architect", "executor_a", "executor_b", "merge"]);
|
|
948
|
-
});
|
|
949
|
-
it("skips downstream nodes when upstream fails (all_success rule)", async () => {
|
|
950
|
-
mockExecuteAgent
|
|
951
|
-
.mockResolvedValueOnce(makeSuccessResult("architect done")) // architect
|
|
952
|
-
.mockResolvedValueOnce(makeFailResult("executor_a exploded")) // executor_a
|
|
953
|
-
.mockResolvedValue(makeSuccessResult("done")); // executor_b (and any others)
|
|
954
|
-
const result = await runWorkflow(PARALLEL_WORKFLOW, BASE_CTX, {}, {});
|
|
955
|
-
// merge depends on both executor_a AND executor_b — executor_a failed, so merge is skipped
|
|
956
|
-
const mergePhase = result.phases.find((p) => p.phase === "merge");
|
|
957
|
-
// merge should be skipped (not executed by agent), indicated by success=true with skip output
|
|
958
|
-
expect(mergePhase).toBeDefined();
|
|
959
|
-
expect(mergePhase?.output).toContain("Skipped");
|
|
960
|
-
// total agent calls: architect + executor_a + executor_b = 3 (merge agent not called)
|
|
961
|
-
expect(mockExecuteAgent).toHaveBeenCalledTimes(3);
|
|
962
|
-
});
|
|
963
|
-
it("returns overall success=false if any phase failed", async () => {
|
|
964
|
-
mockExecuteAgent
|
|
965
|
-
.mockResolvedValueOnce(makeSuccessResult("architect done"))
|
|
966
|
-
.mockResolvedValueOnce(makeFailResult("exploded"))
|
|
967
|
-
.mockResolvedValue(makeSuccessResult("done"));
|
|
968
|
-
const result = await runWorkflow(PARALLEL_WORKFLOW, BASE_CTX, {}, {});
|
|
969
|
-
expect(result.success).toBe(false);
|
|
970
|
-
});
|
|
971
|
-
it("all_done trigger rule: downstream runs even when upstream failed", async () => {
|
|
972
|
-
const workflow = {
|
|
973
|
-
kind: "agent",
|
|
974
|
-
name: "all-done-test",
|
|
975
|
-
phases: [
|
|
976
|
-
{ name: "step_a", type: "agent", prompt: "prompts/a.md", depends_on: [] },
|
|
977
|
-
{
|
|
978
|
-
name: "cleanup",
|
|
979
|
-
type: "agent",
|
|
980
|
-
prompt: "prompts/cleanup.md",
|
|
981
|
-
depends_on: ["step_a"],
|
|
982
|
-
trigger_rule: "all_done",
|
|
983
|
-
},
|
|
984
|
-
],
|
|
985
|
-
};
|
|
986
|
-
mockExecuteAgent
|
|
987
|
-
.mockResolvedValueOnce(makeFailResult("step_a failed"))
|
|
988
|
-
.mockResolvedValueOnce(makeSuccessResult("cleanup done"));
|
|
989
|
-
const result = await runWorkflow(workflow, BASE_CTX, {}, {});
|
|
990
|
-
const phaseNames = result.phases.map((p) => p.phase);
|
|
991
|
-
expect(phaseNames).toContain("cleanup");
|
|
992
|
-
});
|
|
993
|
-
it("sequential path is unchanged when no phase has depends_on", async () => {
|
|
994
|
-
mockExecuteAgent.mockResolvedValue(makeSuccessResult());
|
|
995
|
-
const result = await runWorkflow(SIMPLE_WORKFLOW, BASE_CTX, {}, {});
|
|
996
|
-
expect(result.success).toBe(true);
|
|
997
|
-
expect(mockExecuteAgent).toHaveBeenCalledTimes(2); // architect + executor
|
|
998
|
-
});
|
|
999
|
-
it("uses ONE shared workspace (taskId) for every phase of a DAG run", async () => {
|
|
1000
|
-
mockExecuteAgent.mockResolvedValue(makeSuccessResult());
|
|
1001
|
-
await runWorkflow(PARALLEL_WORKFLOW, BASE_CTX, {}, {});
|
|
1002
|
-
// Every executeAgent call carries the same taskId — the per-phase
|
|
1003
|
-
// `${taskId}-${phaseName}` clones of the old DAG runner are gone.
|
|
1004
|
-
const taskIds = mockExecuteAgent.mock.calls.map((c) => c[2].taskId);
|
|
1005
|
-
expect(taskIds.length).toBe(4);
|
|
1006
|
-
expect(new Set(taskIds)).toEqual(new Set([BASE_CTX.taskId]));
|
|
1007
|
-
});
|
|
1008
|
-
it("records skipped phases in the executions ledger", async () => {
|
|
1009
|
-
mockExecuteAgent
|
|
1010
|
-
.mockResolvedValueOnce(makeSuccessResult("architect done"))
|
|
1011
|
-
.mockResolvedValueOnce(makeFailResult("executor_a exploded"))
|
|
1012
|
-
.mockResolvedValue(makeSuccessResult("done"));
|
|
1013
|
-
const db = makeMockDb("phase_0");
|
|
1014
|
-
const recordSkipped = vi.fn();
|
|
1015
|
-
db.executions.recordSkippedPhase = recordSkipped;
|
|
1016
|
-
await runWorkflow(PARALLEL_WORKFLOW, BASE_CTX, {}, {}, db, undefined, undefined, "wf-skip-1");
|
|
1017
|
-
// merge is skipped (executor_a failed) → one skip row in the ledger.
|
|
1018
|
-
const skippedSkills = recordSkipped.mock.calls.map((c) => c[0]);
|
|
1019
|
-
expect(skippedSkills).toContain("parallel-test:merge");
|
|
1020
|
-
});
|
|
1021
|
-
});
|
|
1022
|
-
describe("runWorkflow — definition-driven resume + YAML messages", () => {
|
|
1023
|
-
beforeEach(() => {
|
|
1024
|
-
vi.clearAllMocks();
|
|
1025
|
-
});
|
|
1026
|
-
it("resumes a workflow whose phases have nothing to do with the build cycle", async () => {
|
|
1027
|
-
const workflow = {
|
|
1028
|
-
kind: "agent",
|
|
1029
|
-
name: "custom",
|
|
1030
|
-
phases: [
|
|
1031
|
-
{ name: "phase_0", type: "context" },
|
|
1032
|
-
{ name: "discover", type: "agent", prompt: "prompts/discover.md" },
|
|
1033
|
-
{ name: "plan", type: "agent", prompt: "prompts/plan.md" },
|
|
1034
|
-
{ name: "implement", type: "agent", prompt: "prompts/implement.md" },
|
|
1035
|
-
{ name: "ship", type: "agent", prompt: "prompts/ship.md" },
|
|
1036
|
-
],
|
|
1037
|
-
};
|
|
1038
|
-
// Ledger-driven resume: discover + plan are already done; implement + ship
|
|
1039
|
-
// still need to run. The runner re-runs from the top and dedups the
|
|
1040
|
-
// completed phases via shouldRunPhase.
|
|
1041
|
-
const db = makeMockDb("plan");
|
|
1042
|
-
const done = new Set(["custom:discover", "custom:plan"]);
|
|
1043
|
-
vi.mocked(db.executions.shouldRunPhase).mockImplementation((skill) => done.has(skill) ? "done" : "run");
|
|
1044
|
-
mockExecuteAgent
|
|
1045
|
-
.mockResolvedValueOnce(makeSuccessResult("implement done"))
|
|
1046
|
-
.mockResolvedValueOnce(makeSuccessResult("ship done"));
|
|
1047
|
-
const result = await runWorkflow(workflow, BASE_CTX, {}, {}, db, undefined, undefined, "wf-custom-resume");
|
|
1048
|
-
expect(result.success).toBe(true);
|
|
1049
|
-
// Only implement + ship actually dispatched an agent — discover + plan deduped.
|
|
1050
|
-
expect(mockExecuteAgent).toHaveBeenCalledTimes(2);
|
|
1051
|
-
const startedSkills = vi.mocked(db.executions.recordStart).mock.calls.map((c) => c[0].skill);
|
|
1052
|
-
expect(startedSkills).toContain("custom:implement");
|
|
1053
|
-
expect(startedSkills).toContain("custom:ship");
|
|
1054
|
-
expect(startedSkills).not.toContain("custom:discover");
|
|
1055
|
-
expect(startedSkills).not.toContain("custom:plan");
|
|
1056
|
-
});
|
|
1057
|
-
it("renders phase.messages.on_start through the template engine", async () => {
|
|
1058
|
-
const workflow = {
|
|
1059
|
-
kind: "agent",
|
|
1060
|
-
name: "msg-test",
|
|
1061
|
-
phases: [
|
|
1062
|
-
{ name: "phase_0", type: "context" },
|
|
1063
|
-
{
|
|
1064
|
-
name: "step",
|
|
1065
|
-
type: "agent",
|
|
1066
|
-
prompt: "prompts/step.md",
|
|
1067
|
-
messages: {
|
|
1068
|
-
on_start: "starting {{step}} for #{{issueNumber}}",
|
|
1069
|
-
on_success: "finished",
|
|
1070
|
-
},
|
|
1071
|
-
},
|
|
1072
|
-
],
|
|
1073
|
-
};
|
|
1074
|
-
mockExecuteAgent.mockResolvedValueOnce(makeSuccessResult("ok"));
|
|
1075
|
-
const comments = [];
|
|
1076
|
-
const ctx = { ...BASE_CTX, step: "step" };
|
|
1077
|
-
await runWorkflow(workflow, ctx, {}, {
|
|
1078
|
-
postComment: async (m) => { comments.push(m); },
|
|
1079
|
-
});
|
|
1080
|
-
expect(comments).toContain("starting step for #42");
|
|
1081
|
-
expect(comments).toContain("finished");
|
|
1082
|
-
});
|
|
1083
|
-
it("uses the definition name as the dedup key prefix", async () => {
|
|
1084
|
-
const workflow = {
|
|
1085
|
-
kind: "agent",
|
|
1086
|
-
name: "my-custom-workflow",
|
|
1087
|
-
phases: [
|
|
1088
|
-
{ name: "phase_0", type: "context" },
|
|
1089
|
-
{ name: "solo", type: "agent", prompt: "prompts/solo.md" },
|
|
1090
|
-
],
|
|
1091
|
-
};
|
|
1092
|
-
mockExecuteAgent.mockResolvedValueOnce(makeSuccessResult("done"));
|
|
1093
|
-
const db = makeMockDb("phase_0");
|
|
1094
|
-
const shouldRunPhase = vi.mocked(db.executions.shouldRunPhase);
|
|
1095
|
-
await runWorkflow(workflow, BASE_CTX, {}, {}, db, undefined, undefined, "wf-dedup-1");
|
|
1096
|
-
expect(shouldRunPhase).toHaveBeenCalledWith("my-custom-workflow:solo", expect.any(String), "wf-dedup-1");
|
|
1097
|
-
});
|
|
1098
|
-
it("exposes reviewer loop output via output_var for downstream templates", async () => {
|
|
1099
|
-
const workflow = {
|
|
1100
|
-
kind: "agent",
|
|
1101
|
-
name: "review-then-summarize",
|
|
1102
|
-
phases: [
|
|
1103
|
-
{ name: "phase_0", type: "context" },
|
|
1104
|
-
{ name: "executor", type: "agent", prompt: "prompts/executor.md" },
|
|
1105
|
-
{
|
|
1106
|
-
name: "reviewer",
|
|
1107
|
-
type: "agent",
|
|
1108
|
-
prompt: "prompts/reviewer.md",
|
|
1109
|
-
output_var: "review",
|
|
1110
|
-
loop: {
|
|
1111
|
-
max_cycles: 1,
|
|
1112
|
-
on_request_changes: {
|
|
1113
|
-
fix_prompt: "prompts/fix.md",
|
|
1114
|
-
re_review_prompt: "prompts/re-reviewer.md",
|
|
1115
|
-
},
|
|
1116
|
-
},
|
|
1117
|
-
},
|
|
1118
|
-
{ name: "summary", type: "agent", prompt: "prompts/summary.md" },
|
|
1119
|
-
],
|
|
1120
|
-
};
|
|
1121
|
-
mockLoadPromptTemplate.mockImplementation((p) => p === "prompts/summary.md" ? "approved={{review.approved}} cycles={{review.cycles}}" : `TEMPLATE:${p}`);
|
|
1122
|
-
mockExecuteAgent
|
|
1123
|
-
.mockResolvedValueOnce(makeSuccessResult("executor"))
|
|
1124
|
-
.mockResolvedValueOnce(makeSuccessResult("VERDICT: APPROVED"))
|
|
1125
|
-
.mockResolvedValueOnce(makeSuccessResult("summary ok"));
|
|
1126
|
-
await runWorkflow(workflow, BASE_CTX, {}, {});
|
|
1127
|
-
const summaryCall = mockExecuteAgent.mock.calls[2][0];
|
|
1128
|
-
expect(summaryCall).toContain("approved=true");
|
|
1129
|
-
expect(summaryCall).toContain("cycles=0");
|
|
1130
|
-
});
|
|
1131
|
-
});
|
|
1132
|
-
describe("runWorkflow — DAG unexpected throws", () => {
|
|
1133
|
-
beforeEach(() => {
|
|
1134
|
-
vi.clearAllMocks();
|
|
1135
|
-
});
|
|
1136
|
-
it("unexpected throw in executeAgent marks phase failed and overall success=false", async () => {
|
|
1137
|
-
// architect succeeds, executor_a throws (unexpected exception, not a normal fail result)
|
|
1138
|
-
mockExecuteAgent
|
|
1139
|
-
.mockResolvedValueOnce(makeSuccessResult("architect done")) // architect
|
|
1140
|
-
.mockRejectedValueOnce(new Error("OOM: process killed")) // executor_a throws
|
|
1141
|
-
.mockResolvedValue(makeSuccessResult("done")); // executor_b and any others
|
|
1142
|
-
const result = await runWorkflow(PARALLEL_WORKFLOW, BASE_CTX, {}, {});
|
|
1143
|
-
// Overall workflow must report failure — not silently succeed
|
|
1144
|
-
expect(result.success).toBe(false);
|
|
1145
|
-
// The failed phase must appear in phases[] with success=false
|
|
1146
|
-
const failedPhase = result.phases.find((p) => p.phase === "executor_a");
|
|
1147
|
-
expect(failedPhase).toBeDefined();
|
|
1148
|
-
expect(failedPhase?.success).toBe(false);
|
|
1149
|
-
// merge depends on both executor_a and executor_b — executor_a failed, so merge is skipped
|
|
1150
|
-
const mergePhase = result.phases.find((p) => p.phase === "merge");
|
|
1151
|
-
expect(mergePhase?.output).toContain("Skipped");
|
|
1152
|
-
});
|
|
1153
|
-
});
|
|
1154
|
-
describe("gitAccessProfileForWorkflow — security workflows", () => {
|
|
1155
|
-
it("returns issues-write for security-review", () => {
|
|
1156
|
-
expect(gitAccessProfileForWorkflow("security-review")).toBe("issues-write");
|
|
1157
|
-
});
|
|
1158
|
-
it("returns repo-write for security-feedback", () => {
|
|
1159
|
-
expect(gitAccessProfileForWorkflow("security-feedback")).toBe("repo-write");
|
|
1160
|
-
});
|
|
1161
|
-
it("returns read for unknown workflow", () => {
|
|
1162
|
-
expect(gitAccessProfileForWorkflow("unknown-workflow")).toBe("read");
|
|
1163
|
-
});
|
|
1164
|
-
});
|
|
1165
|
-
describe("gitSandboxAccessForWorkflow — App PEM never enters the sandbox", () => {
|
|
1166
|
-
// The github extension skips (pem-unreadable) instead of falling back to
|
|
1167
|
-
// GITHUB_TOKEN when App creds are present but the PEM is unreadable in the
|
|
1168
|
-
// sandbox. The harness mints a scoped token, so we never forward App creds.
|
|
1169
|
-
it.each(["build", "pr-fix", "security-feedback", "pr-review", "issue-triage"])("sets allowMcpAppAuth=false for %s (even repo-write)", (wf) => {
|
|
1170
|
-
const access = gitSandboxAccessForWorkflow(wf, "owner", "repo");
|
|
1171
|
-
expect(access.allowMcpAppAuth).toBe(false);
|
|
1172
|
-
});
|
|
1173
|
-
});
|
|
1174
|
-
//# sourceMappingURL=runner.test.js.map
|