@studio-foundation/engine 0.3.0-beta.1
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/ARCHITECTURE.md +57 -0
- package/LICENSE +663 -0
- package/README.md +118 -0
- package/package.json +47 -0
- package/src/__tests__/__fixtures__/script-stage/contracts/book-context.contract.yaml +4 -0
- package/src/__tests__/engine.conditions-group.test.ts +244 -0
- package/src/__tests__/engine.conditions.test.ts +186 -0
- package/src/__tests__/engine.resume.test.ts +108 -0
- package/src/__tests__/engine.script-stage.test.ts +125 -0
- package/src/db/client.ts +5 -0
- package/src/engine.context-event.test.ts +175 -0
- package/src/engine.ts +491 -0
- package/src/events.ts +167 -0
- package/src/index.ts +64 -0
- package/src/pipeline/agent-loader.test.ts +151 -0
- package/src/pipeline/agent-loader.ts +39 -0
- package/src/pipeline/condition-evaluator.test.ts +129 -0
- package/src/pipeline/condition-evaluator.ts +121 -0
- package/src/pipeline/context-pack-loader.ts +63 -0
- package/src/pipeline/context-propagation.test.ts +237 -0
- package/src/pipeline/context-propagation.ts +235 -0
- package/src/pipeline/contract-loader.ts +41 -0
- package/src/pipeline/group-orchestrator.ts +483 -0
- package/src/pipeline/hook-executor.test.ts +121 -0
- package/src/pipeline/hook-executor.ts +87 -0
- package/src/pipeline/invariants-loader.test.ts +51 -0
- package/src/pipeline/invariants-loader.ts +14 -0
- package/src/pipeline/loader.test.ts +128 -0
- package/src/pipeline/loader.ts +149 -0
- package/src/pipeline/output-validator.test.ts +100 -0
- package/src/pipeline/output-validator.ts +40 -0
- package/src/pipeline/post-validator.ts +124 -0
- package/src/pipeline/skill-loader.test.ts +44 -0
- package/src/pipeline/skill-loader.ts +32 -0
- package/src/pipeline/stage-executor.ts +654 -0
- package/src/pipeline/stage-resolver.ts +8 -0
- package/src/pipeline/startup-executor.test.ts +37 -0
- package/src/pipeline/startup-executor.ts +32 -0
- package/src/pipeline/types.ts +42 -0
- package/src/repo-resolver.ts +61 -0
- package/src/spawners/direct-engine-spawner.ts +26 -0
- package/src/state/run-store.test.ts +157 -0
- package/src/state/run-store.ts +362 -0
- package/src/state/state-machine.ts +35 -0
- package/src/state/status-derivation.ts +36 -0
- package/tests/context-pack-loader.test.ts +113 -0
- package/tests/context-propagation.test.ts +267 -0
- package/tests/direct-engine-spawner.test.ts +102 -0
- package/tests/e2e/feature-v5.test.ts +57 -0
- package/tests/events.test.ts +56 -0
- package/tests/fixtures/agents/test-agent.agent.yaml +5 -0
- package/tests/fixtures/contracts/code-gen.contract.yaml +6 -0
- package/tests/fixtures/contracts/qa-gate.contract.yaml +15 -0
- package/tests/fixtures/contracts/test-contract.contract.yaml +6 -0
- package/tests/fixtures/pipelines/group-test.pipeline.yaml +40 -0
- package/tests/fixtures/pipelines/simple.pipeline.yaml +15 -0
- package/tests/fixtures/pipelines/two-stage.pipeline.yaml +24 -0
- package/tests/fixtures/software/pipelines/feature-builder.pipeline.yaml +75 -0
- package/tests/fixtures/test-project/agents/test-agent.agent.yaml +5 -0
- package/tests/fixtures/test-project/contracts/basic-result.contract.yaml +6 -0
- package/tests/fixtures/test-project/contracts/code-gen.contract.yaml +6 -0
- package/tests/fixtures/test-project/contracts/maximum-only.contract.yaml +8 -0
- package/tests/fixtures/test-project/contracts/qa-gate.contract.yaml +15 -0
- package/tests/fixtures/test-project/contracts/strict-result.contract.yaml +7 -0
- package/tests/fixtures/test-project/contracts/test-contract.contract.yaml +6 -0
- package/tests/fixtures/test-project/pipelines/group-simple.pipeline.yaml +26 -0
- package/tests/fixtures/test-project/pipelines/group-test.pipeline.yaml +40 -0
- package/tests/fixtures/test-project/pipelines/hook-output-template.pipeline.yaml +19 -0
- package/tests/fixtures/test-project/pipelines/hook-reject-on-failure.pipeline.yaml +19 -0
- package/tests/fixtures/test-project/pipelines/maximum-only.pipeline.yaml +15 -0
- package/tests/fixtures/test-project/pipelines/parallel-collect-all-test.pipeline.yaml +39 -0
- package/tests/fixtures/test-project/pipelines/parallel-context-isolation-test.pipeline.yaml +38 -0
- package/tests/fixtures/test-project/pipelines/parallel-fail-test.pipeline.yaml +39 -0
- package/tests/fixtures/test-project/pipelines/parallel-test.pipeline.yaml +39 -0
- package/tests/fixtures/test-project/pipelines/parallel-then-sequential-test.pipeline.yaml +38 -0
- package/tests/fixtures/test-project/pipelines/simple.pipeline.yaml +15 -0
- package/tests/fixtures/test-project/pipelines/two-stage.pipeline.yaml +24 -0
- package/tests/fixtures/test-project/pipelines/with-startup.pipeline.yaml +19 -0
- package/tests/loader.test.ts +385 -0
- package/tests/post-validator.test.ts +297 -0
- package/tests/repo-resolver.test.ts +102 -0
- package/tests/run-store.test.ts +143 -0
- package/tests/state-machine.test.ts +110 -0
- package/tests/unit/context-propagation.test.ts +45 -0
- package/tests/unit/engine.test.ts +770 -0
- package/tests/unit/group-loop.test.ts +389 -0
- package/tests/unit/group-parallel.test.ts +423 -0
- package/tests/unit/state/status-derivation.test.ts +88 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { PipelineEngine, type EngineConfig, type RunInput } from '../../src/engine.js';
|
|
4
|
+
import { InMemoryRunStore } from '../../src/state/run-store.js';
|
|
5
|
+
import type { EngineEvents } from '../../src/events.js';
|
|
6
|
+
import { ToolRegistry } from '@studio-foundation/runner';
|
|
7
|
+
|
|
8
|
+
// Provider mock that hangs forever unless the abort signal fires.
|
|
9
|
+
// Using a never-resolving inner promise means the abort is the ONLY way
|
|
10
|
+
// the test completes — no timing race possible.
|
|
11
|
+
function createHangingProvider() {
|
|
12
|
+
return {
|
|
13
|
+
name: 'anthropic',
|
|
14
|
+
call: vi.fn().mockImplementation(
|
|
15
|
+
(_req: unknown, _onToken: unknown, signal?: AbortSignal) => {
|
|
16
|
+
if (signal?.aborted) {
|
|
17
|
+
return Promise.reject(new DOMException('Aborted', 'AbortError'));
|
|
18
|
+
}
|
|
19
|
+
return new Promise<never>((_, reject) => {
|
|
20
|
+
if (!signal) return; // never resolves (only reached if no signal)
|
|
21
|
+
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true });
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Minimal mock for ProviderRegistry
|
|
29
|
+
function createMockProviderRegistry() {
|
|
30
|
+
const mockProvider = {
|
|
31
|
+
name: 'anthropic',
|
|
32
|
+
call: vi.fn().mockResolvedValue({
|
|
33
|
+
content: JSON.stringify({
|
|
34
|
+
summary: 'Test summary',
|
|
35
|
+
requirements: ['req1'],
|
|
36
|
+
acceptance_criteria: ['ac1'],
|
|
37
|
+
steps: ['step1'],
|
|
38
|
+
files_to_modify: ['file.ts'],
|
|
39
|
+
risks: ['none'],
|
|
40
|
+
files_changed: ['file.ts'],
|
|
41
|
+
status: 'pass',
|
|
42
|
+
issues: [],
|
|
43
|
+
}),
|
|
44
|
+
tool_calls: [],
|
|
45
|
+
finish_reason: 'stop',
|
|
46
|
+
usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 },
|
|
47
|
+
}),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
get: vi.fn().mockReturnValue(mockProvider),
|
|
52
|
+
register: vi.fn(),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Minimal mock for ToolRegistry
|
|
57
|
+
function createMockToolRegistry() {
|
|
58
|
+
return {
|
|
59
|
+
register: vi.fn(),
|
|
60
|
+
get: vi.fn(),
|
|
61
|
+
has: vi.fn().mockReturnValue(false),
|
|
62
|
+
list: vi.fn().mockReturnValue([]),
|
|
63
|
+
toToolDefinitions: vi.fn().mockReturnValue([]),
|
|
64
|
+
filter: vi.fn().mockReturnThis(),
|
|
65
|
+
getActiveSnippets: vi.fn().mockReturnValue([]),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const FIXTURES_DIR = join(import.meta.dirname, '..', 'fixtures');
|
|
70
|
+
const PROJECT_DIR = join(FIXTURES_DIR, 'test-project');
|
|
71
|
+
const PIPELINES_DIR = join(PROJECT_DIR, 'pipelines');
|
|
72
|
+
const AGENTS_DIR = join(PROJECT_DIR, 'agents');
|
|
73
|
+
const CONTRACTS_DIR = join(PROJECT_DIR, 'contracts');
|
|
74
|
+
|
|
75
|
+
// Create minimal test fixtures
|
|
76
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
77
|
+
|
|
78
|
+
function setupTestFixtures() {
|
|
79
|
+
mkdirSync(PIPELINES_DIR, { recursive: true });
|
|
80
|
+
mkdirSync(AGENTS_DIR, { recursive: true });
|
|
81
|
+
mkdirSync(CONTRACTS_DIR, { recursive: true });
|
|
82
|
+
|
|
83
|
+
writeFileSync(join(PIPELINES_DIR, 'simple.pipeline.yaml'), `
|
|
84
|
+
name: simple
|
|
85
|
+
description: Simple test pipeline
|
|
86
|
+
version: 1
|
|
87
|
+
stages:
|
|
88
|
+
- name: analysis
|
|
89
|
+
kind: analysis
|
|
90
|
+
agent: test-agent
|
|
91
|
+
contract: test-contract
|
|
92
|
+
ralph:
|
|
93
|
+
max_attempts: 2
|
|
94
|
+
retry_strategy: none
|
|
95
|
+
context:
|
|
96
|
+
include:
|
|
97
|
+
- input
|
|
98
|
+
`);
|
|
99
|
+
|
|
100
|
+
writeFileSync(join(PIPELINES_DIR, 'two-stage.pipeline.yaml'), `
|
|
101
|
+
name: two-stage
|
|
102
|
+
description: Two stage pipeline
|
|
103
|
+
version: 1
|
|
104
|
+
stages:
|
|
105
|
+
- name: stage-1
|
|
106
|
+
kind: analysis
|
|
107
|
+
agent: test-agent
|
|
108
|
+
ralph:
|
|
109
|
+
max_attempts: 1
|
|
110
|
+
retry_strategy: none
|
|
111
|
+
context:
|
|
112
|
+
include:
|
|
113
|
+
- input
|
|
114
|
+
- name: stage-2
|
|
115
|
+
kind: planning
|
|
116
|
+
agent: test-agent
|
|
117
|
+
ralph:
|
|
118
|
+
max_attempts: 1
|
|
119
|
+
retry_strategy: none
|
|
120
|
+
context:
|
|
121
|
+
include:
|
|
122
|
+
- input
|
|
123
|
+
- previous_stage_output
|
|
124
|
+
`);
|
|
125
|
+
|
|
126
|
+
writeFileSync(join(AGENTS_DIR, 'test-agent.agent.yaml'), `
|
|
127
|
+
name: test-agent
|
|
128
|
+
provider: anthropic
|
|
129
|
+
model: claude-sonnet-4-20250514
|
|
130
|
+
temperature: 0.3
|
|
131
|
+
`);
|
|
132
|
+
|
|
133
|
+
writeFileSync(join(CONTRACTS_DIR, 'test-contract.contract.yaml'), `
|
|
134
|
+
name: test-contract
|
|
135
|
+
version: 1
|
|
136
|
+
schema:
|
|
137
|
+
required_fields:
|
|
138
|
+
- summary
|
|
139
|
+
`);
|
|
140
|
+
writeFileSync(join(PIPELINES_DIR, 'hook-output-template.pipeline.yaml'), `
|
|
141
|
+
name: hook-output-template
|
|
142
|
+
description: Pipeline that verifies on_stage_complete hook receives stage output as template context
|
|
143
|
+
version: 1
|
|
144
|
+
stages:
|
|
145
|
+
- name: analysis
|
|
146
|
+
kind: analysis
|
|
147
|
+
agent: test-agent
|
|
148
|
+
contract: test-contract
|
|
149
|
+
hooks:
|
|
150
|
+
on_stage_complete:
|
|
151
|
+
- command: "sh -c 'echo {{output.files_changed}} | grep -qx file.ts'"
|
|
152
|
+
on_failure: reject
|
|
153
|
+
ralph:
|
|
154
|
+
max_attempts: 1
|
|
155
|
+
retry_strategy: none
|
|
156
|
+
context:
|
|
157
|
+
include:
|
|
158
|
+
- input
|
|
159
|
+
`);
|
|
160
|
+
writeFileSync(join(PIPELINES_DIR, 'hook-reject-on-failure.pipeline.yaml'), `
|
|
161
|
+
name: hook-reject-on-failure
|
|
162
|
+
description: Pipeline where on_stage_complete hook fails and rejects the stage
|
|
163
|
+
version: 1
|
|
164
|
+
stages:
|
|
165
|
+
- name: analysis
|
|
166
|
+
kind: analysis
|
|
167
|
+
agent: test-agent
|
|
168
|
+
contract: test-contract
|
|
169
|
+
hooks:
|
|
170
|
+
on_stage_complete:
|
|
171
|
+
- command: "sh -c 'echo hook-error-output >&2; exit 1'"
|
|
172
|
+
on_failure: reject
|
|
173
|
+
ralph:
|
|
174
|
+
max_attempts: 1
|
|
175
|
+
retry_strategy: none
|
|
176
|
+
context:
|
|
177
|
+
include:
|
|
178
|
+
- input
|
|
179
|
+
`);
|
|
180
|
+
|
|
181
|
+
// Fixtures for maximum-only tool_calls contract
|
|
182
|
+
writeFileSync(join(CONTRACTS_DIR, 'maximum-only.contract.yaml'), `
|
|
183
|
+
name: maximum-only
|
|
184
|
+
version: 1
|
|
185
|
+
schema:
|
|
186
|
+
required_fields:
|
|
187
|
+
- summary
|
|
188
|
+
tool_calls:
|
|
189
|
+
maximum: 2
|
|
190
|
+
`);
|
|
191
|
+
writeFileSync(join(PIPELINES_DIR, 'maximum-only.pipeline.yaml'), `
|
|
192
|
+
name: maximum-only
|
|
193
|
+
description: Pipeline with a contract that only sets maximum tool_calls
|
|
194
|
+
version: 1
|
|
195
|
+
stages:
|
|
196
|
+
- name: analysis
|
|
197
|
+
kind: analysis
|
|
198
|
+
agent: test-agent
|
|
199
|
+
contract: maximum-only
|
|
200
|
+
ralph:
|
|
201
|
+
max_attempts: 1
|
|
202
|
+
retry_strategy: none
|
|
203
|
+
context:
|
|
204
|
+
include:
|
|
205
|
+
- input
|
|
206
|
+
`);
|
|
207
|
+
|
|
208
|
+
// Fixture for on_pipeline_start integration test
|
|
209
|
+
writeFileSync(join(PIPELINES_DIR, 'with-startup.pipeline.yaml'), `
|
|
210
|
+
name: with-startup
|
|
211
|
+
description: Pipeline that exercises on_pipeline_start context injection
|
|
212
|
+
version: 1
|
|
213
|
+
on_pipeline_start:
|
|
214
|
+
- command: "echo git-status-output"
|
|
215
|
+
inject_as: git_status
|
|
216
|
+
stages:
|
|
217
|
+
- name: analysis
|
|
218
|
+
kind: analysis
|
|
219
|
+
agent: test-agent
|
|
220
|
+
contract: test-contract
|
|
221
|
+
ralph:
|
|
222
|
+
max_attempts: 1
|
|
223
|
+
retry_strategy: none
|
|
224
|
+
context:
|
|
225
|
+
include:
|
|
226
|
+
- input
|
|
227
|
+
- pipeline_start_context
|
|
228
|
+
`);
|
|
229
|
+
|
|
230
|
+
writeFileSync(join(PIPELINES_DIR, 'group-simple.pipeline.yaml'), `
|
|
231
|
+
name: group-simple
|
|
232
|
+
description: Pipeline with a simple group for cancellation testing
|
|
233
|
+
version: 1
|
|
234
|
+
stages:
|
|
235
|
+
- group: review-loop
|
|
236
|
+
max_iterations: 2
|
|
237
|
+
stages:
|
|
238
|
+
- name: review-stage-1
|
|
239
|
+
kind: analysis
|
|
240
|
+
agent: test-agent
|
|
241
|
+
ralph:
|
|
242
|
+
max_attempts: 1
|
|
243
|
+
retry_strategy: none
|
|
244
|
+
context:
|
|
245
|
+
include:
|
|
246
|
+
- input
|
|
247
|
+
- name: review-stage-2
|
|
248
|
+
kind: analysis
|
|
249
|
+
agent: test-agent
|
|
250
|
+
ralph:
|
|
251
|
+
max_attempts: 1
|
|
252
|
+
retry_strategy: none
|
|
253
|
+
context:
|
|
254
|
+
include:
|
|
255
|
+
- input
|
|
256
|
+
`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Setup fixtures before all tests
|
|
260
|
+
setupTestFixtures();
|
|
261
|
+
|
|
262
|
+
function createTestEngine(overrides: Partial<EngineConfig> = {}): PipelineEngine {
|
|
263
|
+
return new PipelineEngine({
|
|
264
|
+
configsDir: PROJECT_DIR,
|
|
265
|
+
providerRegistry: createMockProviderRegistry() as any,
|
|
266
|
+
toolRegistry: createMockToolRegistry() as any,
|
|
267
|
+
db: new InMemoryRunStore(),
|
|
268
|
+
...overrides,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
describe('PipelineEngine', () => {
|
|
273
|
+
it('runs a simple single-stage pipeline', async () => {
|
|
274
|
+
const engine = createTestEngine();
|
|
275
|
+
const result = await engine.run({
|
|
276
|
+
pipeline: 'simple',
|
|
277
|
+
input: 'Add a FAQ section',
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
expect(result.status).toBe('success');
|
|
281
|
+
expect(result.pipeline_name).toBe('simple');
|
|
282
|
+
expect(result.stages).toHaveLength(1);
|
|
283
|
+
expect(result.stages[0].stage_name).toBe('analysis');
|
|
284
|
+
expect(result.stages[0].status).toBe('success');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('runs a multi-stage pipeline', async () => {
|
|
288
|
+
const engine = createTestEngine();
|
|
289
|
+
const result = await engine.run({
|
|
290
|
+
pipeline: 'two-stage',
|
|
291
|
+
input: 'Build feature X',
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
expect(result.status).toBe('success');
|
|
295
|
+
expect(result.stages).toHaveLength(2);
|
|
296
|
+
expect(result.stages[0].stage_name).toBe('stage-1');
|
|
297
|
+
expect(result.stages[1].stage_name).toBe('stage-2');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('persists run to store', async () => {
|
|
301
|
+
const store = new InMemoryRunStore();
|
|
302
|
+
const engine = createTestEngine({ db: store });
|
|
303
|
+
|
|
304
|
+
const result = await engine.run({
|
|
305
|
+
pipeline: 'simple',
|
|
306
|
+
input: 'Test persist',
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const stored = store.getPipelineRun(result.id);
|
|
310
|
+
expect(stored).not.toBeNull();
|
|
311
|
+
expect(stored!.id).toBe(result.id);
|
|
312
|
+
expect(stored!.status).toBe('success');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('emits lifecycle events', async () => {
|
|
316
|
+
const events: string[] = [];
|
|
317
|
+
const engineEvents: EngineEvents = {
|
|
318
|
+
onPipelineStart: () => events.push('pipeline_start'),
|
|
319
|
+
onPipelineComplete: () => events.push('pipeline_complete'),
|
|
320
|
+
onStageStart: () => events.push('stage_start'),
|
|
321
|
+
onStageComplete: () => events.push('stage_complete'),
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const engine = new PipelineEngine(
|
|
325
|
+
{
|
|
326
|
+
configsDir: PROJECT_DIR,
|
|
327
|
+
providerRegistry: createMockProviderRegistry() as any,
|
|
328
|
+
toolRegistry: createMockToolRegistry() as any,
|
|
329
|
+
},
|
|
330
|
+
engineEvents
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
await engine.run({ pipeline: 'simple', input: 'test events' });
|
|
334
|
+
|
|
335
|
+
expect(events).toEqual([
|
|
336
|
+
'pipeline_start',
|
|
337
|
+
'stage_start',
|
|
338
|
+
'stage_complete',
|
|
339
|
+
'pipeline_complete',
|
|
340
|
+
]);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('stops pipeline on stage failure (validation fails)', async () => {
|
|
344
|
+
// Provider returns output missing required 'summary' field
|
|
345
|
+
const badProvider = {
|
|
346
|
+
name: 'anthropic',
|
|
347
|
+
call: vi.fn().mockResolvedValue({
|
|
348
|
+
content: JSON.stringify({ no_summary: true }),
|
|
349
|
+
tool_calls: [],
|
|
350
|
+
finish_reason: 'stop',
|
|
351
|
+
}),
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const engine = createTestEngine({
|
|
355
|
+
providerRegistry: { get: vi.fn().mockReturnValue(badProvider), register: vi.fn() } as any,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const result = await engine.run({
|
|
359
|
+
pipeline: 'simple',
|
|
360
|
+
input: 'This should fail validation',
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
expect(result.status).toBe('failed');
|
|
364
|
+
expect(result.stages[0].status).toBe('failed');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('sets completed_at on pipeline run', async () => {
|
|
368
|
+
const engine = createTestEngine();
|
|
369
|
+
const result = await engine.run({
|
|
370
|
+
pipeline: 'simple',
|
|
371
|
+
input: 'test timestamps',
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
expect(result.started_at).toBeDefined();
|
|
375
|
+
expect(result.completed_at).toBeDefined();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('creates proper stage run with tasks and agent runs', async () => {
|
|
379
|
+
const engine = createTestEngine();
|
|
380
|
+
const result = await engine.run({
|
|
381
|
+
pipeline: 'simple',
|
|
382
|
+
input: 'test structure',
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
const stage = result.stages[0];
|
|
386
|
+
expect(stage.tasks).toHaveLength(1);
|
|
387
|
+
|
|
388
|
+
const task = stage.tasks[0];
|
|
389
|
+
expect(task.task_name).toBe('analysis');
|
|
390
|
+
expect(task.agent_runs.length).toBeGreaterThanOrEqual(1);
|
|
391
|
+
|
|
392
|
+
const agentRun = task.agent_runs[0];
|
|
393
|
+
expect(agentRun.agent_name).toBe('test-agent');
|
|
394
|
+
expect(agentRun.attempt).toBe(1);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('throws for non-existent pipeline', async () => {
|
|
398
|
+
const engine = createTestEngine();
|
|
399
|
+
await expect(
|
|
400
|
+
engine.run({ pipeline: 'nonexistent', input: 'test' })
|
|
401
|
+
).rejects.toThrow('Failed to load pipeline');
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('emits enriched stage complete events with summary and tokens', async () => {
|
|
405
|
+
const stageEvents: any[] = [];
|
|
406
|
+
const engineEvents: EngineEvents = {
|
|
407
|
+
onPipelineStart: () => {},
|
|
408
|
+
onPipelineComplete: () => {},
|
|
409
|
+
onStageStart: () => {},
|
|
410
|
+
onStageComplete: (e) => stageEvents.push(e),
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const engine = new PipelineEngine(
|
|
414
|
+
{
|
|
415
|
+
configsDir: PROJECT_DIR,
|
|
416
|
+
providerRegistry: createMockProviderRegistry() as any,
|
|
417
|
+
toolRegistry: createMockToolRegistry() as any,
|
|
418
|
+
},
|
|
419
|
+
engineEvents
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
await engine.run({ pipeline: 'simple', input: 'test enriched events' });
|
|
423
|
+
|
|
424
|
+
expect(stageEvents).toHaveLength(1);
|
|
425
|
+
const e = stageEvents[0];
|
|
426
|
+
expect(e.stage_name).toBe('analysis');
|
|
427
|
+
expect(e.stage_index).toBe(0);
|
|
428
|
+
expect(e.total_stages).toBe(1);
|
|
429
|
+
expect(e.status).toBe('success');
|
|
430
|
+
expect(e.attempts).toBe(1);
|
|
431
|
+
expect(e.duration_ms).toBeGreaterThanOrEqual(0);
|
|
432
|
+
expect(e.output_summary).toBeDefined();
|
|
433
|
+
expect(typeof e.output_summary).toBe('string');
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('emits pipeline complete with totals', async () => {
|
|
437
|
+
const pipelineEvents: any[] = [];
|
|
438
|
+
const engineEvents: EngineEvents = {
|
|
439
|
+
onPipelineComplete: (e) => pipelineEvents.push(e),
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const engine = new PipelineEngine(
|
|
443
|
+
{
|
|
444
|
+
configsDir: PROJECT_DIR,
|
|
445
|
+
providerRegistry: createMockProviderRegistry() as any,
|
|
446
|
+
toolRegistry: createMockToolRegistry() as any,
|
|
447
|
+
},
|
|
448
|
+
engineEvents
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
await engine.run({ pipeline: 'simple', input: 'test totals' });
|
|
452
|
+
|
|
453
|
+
expect(pipelineEvents).toHaveLength(1);
|
|
454
|
+
const e = pipelineEvents[0];
|
|
455
|
+
expect(e.status).toBe('success');
|
|
456
|
+
expect(typeof e.total_tokens).toBe('number');
|
|
457
|
+
expect(typeof e.total_tool_calls).toBe('number');
|
|
458
|
+
expect(typeof e.duration_ms).toBe('number');
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('emits pipeline_complete with status failed when stage fails', async () => {
|
|
462
|
+
const pipelineEvents: any[] = [];
|
|
463
|
+
const badProvider = {
|
|
464
|
+
name: 'anthropic',
|
|
465
|
+
call: vi.fn().mockResolvedValue({
|
|
466
|
+
content: JSON.stringify({ no_summary: true }),
|
|
467
|
+
tool_calls: [],
|
|
468
|
+
finish_reason: 'stop',
|
|
469
|
+
}),
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const engine = new PipelineEngine(
|
|
473
|
+
{
|
|
474
|
+
configsDir: PROJECT_DIR,
|
|
475
|
+
providerRegistry: { get: vi.fn().mockReturnValue(badProvider), register: vi.fn() } as any,
|
|
476
|
+
toolRegistry: createMockToolRegistry() as any,
|
|
477
|
+
},
|
|
478
|
+
{ onPipelineComplete: (e) => pipelineEvents.push(e) }
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
const result = await engine.run({ pipeline: 'simple', input: 'will fail' });
|
|
482
|
+
|
|
483
|
+
expect(result.status).toBe('failed');
|
|
484
|
+
expect(pipelineEvents).toHaveLength(1);
|
|
485
|
+
expect(pipelineEvents[0].status).toBe('failed');
|
|
486
|
+
expect(typeof pipelineEvents[0].duration_ms).toBe('number');
|
|
487
|
+
expect(typeof pipelineEvents[0].total_tokens).toBe('number');
|
|
488
|
+
expect(typeof pipelineEvents[0].total_tool_calls).toBe('number');
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('emits pipeline_complete with status rejected when stage is rejected', async () => {
|
|
492
|
+
const pipelineEvents: any[] = [];
|
|
493
|
+
|
|
494
|
+
const engine = new PipelineEngine(
|
|
495
|
+
{
|
|
496
|
+
configsDir: PROJECT_DIR,
|
|
497
|
+
providerRegistry: createMockProviderRegistry() as any,
|
|
498
|
+
toolRegistry: createMockToolRegistry() as any,
|
|
499
|
+
},
|
|
500
|
+
{ onPipelineComplete: (e) => pipelineEvents.push(e) }
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
const result = await engine.run({ pipeline: 'hook-reject-on-failure', input: 'will reject' });
|
|
504
|
+
|
|
505
|
+
expect(result.status).toBe('rejected');
|
|
506
|
+
expect(pipelineEvents).toHaveLength(1);
|
|
507
|
+
expect(pipelineEvents[0].status).toBe('rejected');
|
|
508
|
+
expect(typeof pipelineEvents[0].duration_ms).toBe('number');
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('on_stage_complete hook receives stage output as template context', async () => {
|
|
512
|
+
const engine = createTestEngine();
|
|
513
|
+
const result = await engine.run({
|
|
514
|
+
pipeline: 'hook-output-template',
|
|
515
|
+
input: 'test',
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// The hook checks {{output.files_changed}} == 'file.ts'
|
|
519
|
+
// The mock provider returns files_changed: ['file.ts'] → renders as 'file.ts'
|
|
520
|
+
// Without the fix: template not resolved → hook fails → stage rejected
|
|
521
|
+
// With the fix: template resolves correctly → hook passes → stage succeeds
|
|
522
|
+
expect(result.status).toBe('success');
|
|
523
|
+
expect(result.stages[0].status).toBe('success');
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it('on_stage_complete hook failure with on_failure:reject causes stage rejection', async () => {
|
|
527
|
+
const engine = createTestEngine();
|
|
528
|
+
const result = await engine.run({
|
|
529
|
+
pipeline: 'hook-reject-on-failure',
|
|
530
|
+
input: 'test',
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
expect(result.status).toBe('rejected');
|
|
534
|
+
expect(result.stages[0].status).toBe('rejected');
|
|
535
|
+
expect(result.stages[0]).toMatchObject({
|
|
536
|
+
status: 'rejected',
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('returns cancelled when signal is aborted before stages run', async () => {
|
|
541
|
+
const controller = new AbortController();
|
|
542
|
+
controller.abort(); // Pre-aborted
|
|
543
|
+
|
|
544
|
+
const engine = createTestEngine();
|
|
545
|
+
const result = await engine.run({
|
|
546
|
+
pipeline: 'simple',
|
|
547
|
+
input: 'test input',
|
|
548
|
+
signal: controller.signal,
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
expect(result.status).toBe('cancelled');
|
|
552
|
+
expect(result.stages).toHaveLength(0);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('cancels cleanly when signal is aborted while a stage is executing', async () => {
|
|
556
|
+
const controller = new AbortController();
|
|
557
|
+
|
|
558
|
+
const engine = createTestEngine({
|
|
559
|
+
providerRegistry: { get: vi.fn().mockReturnValue(createHangingProvider()), register: vi.fn() } as any,
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
// Abort after the engine has started (next microtask), before the hanging provider resolves
|
|
563
|
+
queueMicrotask(() => controller.abort());
|
|
564
|
+
|
|
565
|
+
const result = await engine.run({
|
|
566
|
+
pipeline: 'simple',
|
|
567
|
+
input: 'test input',
|
|
568
|
+
signal: controller.signal,
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
expect(result.status).toBe('cancelled');
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it('cancels between stages when signal is aborted after first stage completes', async () => {
|
|
575
|
+
const controller = new AbortController();
|
|
576
|
+
let stage1Completed = false;
|
|
577
|
+
|
|
578
|
+
const events: EngineEvents = {
|
|
579
|
+
onStageComplete: (e) => {
|
|
580
|
+
if (e.stage_name === 'stage-1') {
|
|
581
|
+
stage1Completed = true;
|
|
582
|
+
controller.abort(); // abort after stage 1 finishes
|
|
583
|
+
}
|
|
584
|
+
},
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
const engine = new PipelineEngine(
|
|
588
|
+
{
|
|
589
|
+
configsDir: PROJECT_DIR,
|
|
590
|
+
providerRegistry: createMockProviderRegistry() as any,
|
|
591
|
+
toolRegistry: createMockToolRegistry() as any,
|
|
592
|
+
db: new InMemoryRunStore(),
|
|
593
|
+
},
|
|
594
|
+
events
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
const result = await engine.run({
|
|
598
|
+
pipeline: 'two-stage',
|
|
599
|
+
input: 'test input',
|
|
600
|
+
signal: controller.signal,
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
expect(result.status).toBe('cancelled');
|
|
604
|
+
expect(stage1Completed).toBe(true);
|
|
605
|
+
expect(result.stages).toHaveLength(1);
|
|
606
|
+
expect(result.stages[0].stage_name).toBe('stage-1');
|
|
607
|
+
expect(result.stages[0].status).toBe('success');
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('cancels cleanly when signal is aborted during a group stage', async () => {
|
|
611
|
+
const controller = new AbortController();
|
|
612
|
+
|
|
613
|
+
const engine = createTestEngine({
|
|
614
|
+
providerRegistry: { get: vi.fn().mockReturnValue(createHangingProvider()), register: vi.fn() } as any,
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
// Abort after the engine has started (next microtask), before the hanging provider resolves
|
|
618
|
+
queueMicrotask(() => controller.abort());
|
|
619
|
+
|
|
620
|
+
const result = await engine.run({
|
|
621
|
+
pipeline: 'group-simple',
|
|
622
|
+
input: 'test input',
|
|
623
|
+
signal: controller.signal,
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
expect(result.status).toBe('cancelled');
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it('on_pipeline_start output is injected into stage context', async () => {
|
|
630
|
+
// The 'with-startup' pipeline runs `echo git-status-output` and injects it
|
|
631
|
+
// as 'git_status'. The stage includes 'pipeline_start_context'.
|
|
632
|
+
// The prompt-builder formats startup_context as:
|
|
633
|
+
// ## Pipeline Startup Context
|
|
634
|
+
// ### git_status
|
|
635
|
+
// ```
|
|
636
|
+
// git-status-output
|
|
637
|
+
// ```
|
|
638
|
+
// So we assert that the provider received a user message containing that value.
|
|
639
|
+
const capturedRequests: any[] = [];
|
|
640
|
+
const provider = {
|
|
641
|
+
name: 'anthropic',
|
|
642
|
+
call: vi.fn().mockImplementation(async (req: any) => {
|
|
643
|
+
capturedRequests.push(req);
|
|
644
|
+
return {
|
|
645
|
+
content: JSON.stringify({ summary: 'done', requirements: [], acceptance_criteria: [] }),
|
|
646
|
+
tool_calls: [],
|
|
647
|
+
finish_reason: 'stop',
|
|
648
|
+
usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 },
|
|
649
|
+
};
|
|
650
|
+
}),
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
const engine = createTestEngine({
|
|
654
|
+
providerRegistry: { get: vi.fn().mockReturnValue(provider), register: vi.fn() } as any,
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
const result = await engine.run({ pipeline: 'with-startup', input: 'test startup' });
|
|
658
|
+
|
|
659
|
+
expect(result.status).toBe('success');
|
|
660
|
+
expect(capturedRequests).toHaveLength(1);
|
|
661
|
+
|
|
662
|
+
const userMessage = capturedRequests[0].messages.find((m: any) => m.role === 'user');
|
|
663
|
+
expect(userMessage).toBeDefined();
|
|
664
|
+
expect(userMessage.content).toContain('git-status-output');
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it('fails stage when tool_calls maximum is exceeded (maximum-only contract)', async () => {
|
|
668
|
+
// A contract with only `maximum: 2` and no `minimum`.
|
|
669
|
+
// Before the guard fix, this validator was silently skipped.
|
|
670
|
+
// After the fix it must be activated and fail when 3 successful tool calls are made.
|
|
671
|
+
|
|
672
|
+
// Create a real ToolRegistry with a succeeding test tool
|
|
673
|
+
const toolRegistry = new ToolRegistry();
|
|
674
|
+
toolRegistry.register({
|
|
675
|
+
name: 'test-tool',
|
|
676
|
+
description: 'A simple test tool',
|
|
677
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
678
|
+
execute: async () => ({ success: true, output: 'ok' }),
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
// Mock provider: returns 3 tool calls on the first call, then valid output
|
|
682
|
+
let callCount = 0;
|
|
683
|
+
const provider = {
|
|
684
|
+
name: 'anthropic',
|
|
685
|
+
call: vi.fn().mockImplementation(async () => {
|
|
686
|
+
callCount++;
|
|
687
|
+
if (callCount === 1) {
|
|
688
|
+
// First turn: 3 tool calls (exceeds maximum of 2)
|
|
689
|
+
return {
|
|
690
|
+
content: '',
|
|
691
|
+
tool_calls: [
|
|
692
|
+
{ id: 'tc-1', name: 'test-tool', arguments: {} },
|
|
693
|
+
{ id: 'tc-2', name: 'test-tool', arguments: {} },
|
|
694
|
+
{ id: 'tc-3', name: 'test-tool', arguments: {} },
|
|
695
|
+
],
|
|
696
|
+
finish_reason: 'tool_calls',
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
// Second turn: final response with valid output
|
|
700
|
+
return {
|
|
701
|
+
content: JSON.stringify({ summary: 'done' }),
|
|
702
|
+
tool_calls: [],
|
|
703
|
+
finish_reason: 'stop',
|
|
704
|
+
usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 },
|
|
705
|
+
};
|
|
706
|
+
}),
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
const engine = new PipelineEngine({
|
|
710
|
+
configsDir: PROJECT_DIR,
|
|
711
|
+
providerRegistry: { get: vi.fn().mockReturnValue(provider), register: vi.fn() } as any,
|
|
712
|
+
toolRegistry: toolRegistry as any,
|
|
713
|
+
db: new InMemoryRunStore(),
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
const result = await engine.run({ pipeline: 'maximum-only', input: 'test maximum' });
|
|
717
|
+
|
|
718
|
+
expect(result.status).toBe('failed');
|
|
719
|
+
expect(result.stages[0].status).toBe('failed');
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it('succeeds stage when tool_calls count is within maximum (maximum-only contract)', async () => {
|
|
723
|
+
// Same contract (maximum: 2), but only 1 tool call made — should pass.
|
|
724
|
+
|
|
725
|
+
const toolRegistry = new ToolRegistry();
|
|
726
|
+
toolRegistry.register({
|
|
727
|
+
name: 'test-tool',
|
|
728
|
+
description: 'A simple test tool',
|
|
729
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
730
|
+
execute: async () => ({ success: true, output: 'ok' }),
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
let callCount = 0;
|
|
734
|
+
const provider = {
|
|
735
|
+
name: 'anthropic',
|
|
736
|
+
call: vi.fn().mockImplementation(async () => {
|
|
737
|
+
callCount++;
|
|
738
|
+
if (callCount === 1) {
|
|
739
|
+
// First turn: 1 tool call (within maximum of 2)
|
|
740
|
+
return {
|
|
741
|
+
content: '',
|
|
742
|
+
tool_calls: [
|
|
743
|
+
{ id: 'tc-1', name: 'test-tool', arguments: {} },
|
|
744
|
+
],
|
|
745
|
+
finish_reason: 'tool_calls',
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
// Second turn: final response with valid output
|
|
749
|
+
return {
|
|
750
|
+
content: JSON.stringify({ summary: 'done' }),
|
|
751
|
+
tool_calls: [],
|
|
752
|
+
finish_reason: 'stop',
|
|
753
|
+
usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 },
|
|
754
|
+
};
|
|
755
|
+
}),
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
const engine = new PipelineEngine({
|
|
759
|
+
configsDir: PROJECT_DIR,
|
|
760
|
+
providerRegistry: { get: vi.fn().mockReturnValue(provider), register: vi.fn() } as any,
|
|
761
|
+
toolRegistry: toolRegistry as any,
|
|
762
|
+
db: new InMemoryRunStore(),
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
const result = await engine.run({ pipeline: 'maximum-only', input: 'test maximum within bounds' });
|
|
766
|
+
|
|
767
|
+
expect(result.status).toBe('success');
|
|
768
|
+
expect(result.stages[0].status).toBe('success');
|
|
769
|
+
});
|
|
770
|
+
});
|