@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,267 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createInitialContext,
|
|
4
|
+
addStageOutput,
|
|
5
|
+
addStageToolResults,
|
|
6
|
+
getContextForStage,
|
|
7
|
+
setGroupFeedback,
|
|
8
|
+
clearGroupFeedback,
|
|
9
|
+
type GroupFeedback,
|
|
10
|
+
} from '../src/pipeline/context-propagation.js';
|
|
11
|
+
import type { StageDefinition, ToolCall } from '@studio-foundation/contracts';
|
|
12
|
+
|
|
13
|
+
function makeStage(overrides: Partial<StageDefinition> = {}): StageDefinition {
|
|
14
|
+
return {
|
|
15
|
+
name: 'test-stage',
|
|
16
|
+
kind: 'analysis',
|
|
17
|
+
agent: 'analyst',
|
|
18
|
+
...overrides,
|
|
19
|
+
} as StageDefinition;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('createInitialContext', () => {
|
|
23
|
+
it('creates context with input', () => {
|
|
24
|
+
const ctx = createInitialContext('Build a FAQ page');
|
|
25
|
+
expect(ctx.input).toBe('Build a FAQ page');
|
|
26
|
+
expect(ctx.stageOutputs.size).toBe(0);
|
|
27
|
+
expect(ctx.stageToolResults.size).toBe(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('optionally includes repoPath', () => {
|
|
31
|
+
const ctx = createInitialContext('test', '/path/to/repo');
|
|
32
|
+
expect(ctx.repoPath).toBe('/path/to/repo');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('addStageOutput', () => {
|
|
37
|
+
it('adds stage output to context', () => {
|
|
38
|
+
const ctx = createInitialContext('test');
|
|
39
|
+
addStageOutput(ctx, 'brief-analysis', { summary: 'done' });
|
|
40
|
+
|
|
41
|
+
expect(ctx.stageOutputs.get('brief-analysis')).toEqual({ summary: 'done' });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('accumulates multiple stage outputs', () => {
|
|
45
|
+
const ctx = createInitialContext('test');
|
|
46
|
+
addStageOutput(ctx, 'stage-1', { result: 'a' });
|
|
47
|
+
addStageOutput(ctx, 'stage-2', { result: 'b' });
|
|
48
|
+
|
|
49
|
+
expect(ctx.stageOutputs.size).toBe(2);
|
|
50
|
+
expect(ctx.stageOutputs.get('stage-1')).toEqual({ result: 'a' });
|
|
51
|
+
expect(ctx.stageOutputs.get('stage-2')).toEqual({ result: 'b' });
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('structured input', () => {
|
|
56
|
+
it('passes structured input as YAML string in additional_context', () => {
|
|
57
|
+
const structuredInput = {
|
|
58
|
+
brief_summary: 'Add FAQ to About page',
|
|
59
|
+
target_page: 'src/pages/about.tsx',
|
|
60
|
+
acceptance_criteria: ['FAQ section appears', 'Accordion style'],
|
|
61
|
+
};
|
|
62
|
+
const context = createInitialContext(structuredInput);
|
|
63
|
+
const agentCtx = getContextForStage(context, {
|
|
64
|
+
name: 'test',
|
|
65
|
+
kind: 'analysis',
|
|
66
|
+
agent: 'test-agent',
|
|
67
|
+
context: { include: ['input'] },
|
|
68
|
+
});
|
|
69
|
+
// Structured input should be serialized as YAML in additional_context
|
|
70
|
+
expect(agentCtx.additional_context).toContain('brief_summary');
|
|
71
|
+
expect(agentCtx.additional_context).toContain('Add FAQ to About page');
|
|
72
|
+
expect(agentCtx.additional_context).toContain('target_page');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('passes string input unchanged', () => {
|
|
76
|
+
const context = createInitialContext('Simple string input');
|
|
77
|
+
const agentCtx = getContextForStage(context, {
|
|
78
|
+
name: 'test',
|
|
79
|
+
kind: 'analysis',
|
|
80
|
+
agent: 'test-agent',
|
|
81
|
+
context: { include: ['input'] },
|
|
82
|
+
});
|
|
83
|
+
expect(agentCtx.additional_context).toBe('Simple string input');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('getContextForStage', () => {
|
|
88
|
+
it('with "input" includes user input as additional_context', () => {
|
|
89
|
+
const ctx = createInitialContext('Build a FAQ');
|
|
90
|
+
const stage = makeStage({ context: { include: ['input'] } });
|
|
91
|
+
|
|
92
|
+
const agentCtx = getContextForStage(ctx, stage);
|
|
93
|
+
expect(agentCtx.additional_context).toBe('Build a FAQ');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('with "previous_stage_output" includes the last stage output', () => {
|
|
97
|
+
const ctx = createInitialContext('test');
|
|
98
|
+
addStageOutput(ctx, 'analysis', { summary: 'looks good' });
|
|
99
|
+
addStageOutput(ctx, 'planning', { steps: [1, 2, 3] });
|
|
100
|
+
|
|
101
|
+
const stage = makeStage({ context: { include: ['previous_stage_output'] } });
|
|
102
|
+
const agentCtx = getContextForStage(ctx, stage, 'planning');
|
|
103
|
+
|
|
104
|
+
expect(agentCtx.previous_outputs).toEqual({
|
|
105
|
+
planning: { steps: [1, 2, 3] },
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('with "all_stage_outputs" includes all accumulated outputs', () => {
|
|
110
|
+
const ctx = createInitialContext('test');
|
|
111
|
+
addStageOutput(ctx, 'stage-1', { result: 'a' });
|
|
112
|
+
addStageOutput(ctx, 'stage-2', { result: 'b' });
|
|
113
|
+
|
|
114
|
+
const stage = makeStage({ context: { include: ['all_stage_outputs'] } });
|
|
115
|
+
const agentCtx = getContextForStage(ctx, stage);
|
|
116
|
+
|
|
117
|
+
expect(agentCtx.previous_outputs).toEqual({
|
|
118
|
+
'stage-1': { result: 'a' },
|
|
119
|
+
'stage-2': { result: 'b' },
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('with "repo_files" sets repo_files to empty array (engine fills later)', () => {
|
|
124
|
+
const ctx = createInitialContext('test', '/repo');
|
|
125
|
+
const stage = makeStage({ context: { include: ['repo_files'] } });
|
|
126
|
+
|
|
127
|
+
const agentCtx = getContextForStage(ctx, stage);
|
|
128
|
+
expect(agentCtx.repo_files).toEqual([]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('defaults to "input" when no context.include specified', () => {
|
|
132
|
+
const ctx = createInitialContext('default input');
|
|
133
|
+
const stage = makeStage({});
|
|
134
|
+
|
|
135
|
+
const agentCtx = getContextForStage(ctx, stage);
|
|
136
|
+
expect(agentCtx.additional_context).toBe('default input');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('combines multiple includes', () => {
|
|
140
|
+
const ctx = createInitialContext('user input');
|
|
141
|
+
addStageOutput(ctx, 'prev', { data: 42 });
|
|
142
|
+
|
|
143
|
+
const stage = makeStage({
|
|
144
|
+
context: { include: ['input', 'previous_stage_output', 'repo_files'] },
|
|
145
|
+
});
|
|
146
|
+
const agentCtx = getContextForStage(ctx, stage, 'prev');
|
|
147
|
+
|
|
148
|
+
expect(agentCtx.additional_context).toBe('user input');
|
|
149
|
+
expect(agentCtx.previous_outputs).toEqual({ prev: { data: 42 } });
|
|
150
|
+
expect(agentCtx.repo_files).toEqual([]);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('group feedback', () => {
|
|
155
|
+
it('setGroupFeedback adds feedback to context', () => {
|
|
156
|
+
const ctx = createInitialContext('test');
|
|
157
|
+
const feedback: GroupFeedback = {
|
|
158
|
+
iteration: 1,
|
|
159
|
+
max_iterations: 3,
|
|
160
|
+
rejection_reason: 'Missing error handling',
|
|
161
|
+
};
|
|
162
|
+
setGroupFeedback(ctx, feedback);
|
|
163
|
+
expect(ctx.groupFeedback).toEqual(feedback);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('clearGroupFeedback removes feedback from context', () => {
|
|
167
|
+
const ctx = createInitialContext('test');
|
|
168
|
+
setGroupFeedback(ctx, {
|
|
169
|
+
iteration: 1,
|
|
170
|
+
max_iterations: 3,
|
|
171
|
+
rejection_reason: 'test',
|
|
172
|
+
});
|
|
173
|
+
clearGroupFeedback(ctx);
|
|
174
|
+
expect(ctx.groupFeedback).toBeUndefined();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('getContextForStage populates group_feedback as dedicated field', () => {
|
|
178
|
+
const ctx = createInitialContext('Build a FAQ');
|
|
179
|
+
setGroupFeedback(ctx, {
|
|
180
|
+
iteration: 1,
|
|
181
|
+
max_iterations: 3,
|
|
182
|
+
rejection_reason: 'Props not passed to component',
|
|
183
|
+
rejection_details: ['Missing onClick handler', 'Wrong prop type'],
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const stage = makeStage({
|
|
187
|
+
context: { include: ['input', 'group_feedback'] },
|
|
188
|
+
});
|
|
189
|
+
const agentCtx = getContextForStage(ctx, stage);
|
|
190
|
+
|
|
191
|
+
// Input goes to additional_context, feedback goes to dedicated field
|
|
192
|
+
expect(agentCtx.additional_context).toBe('Build a FAQ');
|
|
193
|
+
expect(agentCtx.group_feedback).toEqual({
|
|
194
|
+
iteration: 1,
|
|
195
|
+
max_iterations: 3,
|
|
196
|
+
rejection_reason: 'Props not passed to component',
|
|
197
|
+
rejection_details: ['Missing onClick handler', 'Wrong prop type'],
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('group_feedback is ignored when no feedback is set', () => {
|
|
202
|
+
const ctx = createInitialContext('Build a FAQ');
|
|
203
|
+
const stage = makeStage({
|
|
204
|
+
context: { include: ['input', 'group_feedback'] },
|
|
205
|
+
});
|
|
206
|
+
const agentCtx = getContextForStage(ctx, stage);
|
|
207
|
+
|
|
208
|
+
// Only input is present, no feedback
|
|
209
|
+
expect(agentCtx.additional_context).toBe('Build a FAQ');
|
|
210
|
+
expect(agentCtx.group_feedback).toBeUndefined();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('addStageToolResults', () => {
|
|
215
|
+
it('stores tool calls by stage name', () => {
|
|
216
|
+
const ctx = createInitialContext('test');
|
|
217
|
+
const toolCalls: ToolCall[] = [
|
|
218
|
+
{ id: '1', name: 'search-search_codebase', arguments: { pattern: 'about' }, result: { matches: [] } },
|
|
219
|
+
];
|
|
220
|
+
addStageToolResults(ctx, 'brief-analysis', toolCalls);
|
|
221
|
+
expect(ctx.stageToolResults.get('brief-analysis')).toEqual(toolCalls);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('accumulates tool results across stages', () => {
|
|
225
|
+
const ctx = createInitialContext('test');
|
|
226
|
+
addStageToolResults(ctx, 'stage-1', [{ id: '1', name: 'tool-a', arguments: {}, result: 'r1' }]);
|
|
227
|
+
addStageToolResults(ctx, 'stage-2', [{ id: '2', name: 'tool-b', arguments: {}, result: 'r2' }]);
|
|
228
|
+
expect(ctx.stageToolResults.size).toBe(2);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('getContextForStage — previous_stage_tool_results', () => {
|
|
233
|
+
it('includes tool calls from the previous stage', () => {
|
|
234
|
+
const ctx = createInitialContext('test');
|
|
235
|
+
const toolCalls: ToolCall[] = [
|
|
236
|
+
{ id: '1', name: 'search-search_codebase', arguments: { pattern: 'about' }, result: { matches: ['about.tsx'] } },
|
|
237
|
+
];
|
|
238
|
+
addStageToolResults(ctx, 'brief-analysis', toolCalls);
|
|
239
|
+
|
|
240
|
+
const stage = makeStage({ context: { include: ['previous_stage_tool_results'] } });
|
|
241
|
+
const agentCtx = getContextForStage(ctx, stage, 'brief-analysis');
|
|
242
|
+
|
|
243
|
+
expect(agentCtx.previous_tool_results).toEqual({ 'brief-analysis': toolCalls });
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('returns empty when no previous stage tool results', () => {
|
|
247
|
+
const ctx = createInitialContext('test');
|
|
248
|
+
const stage = makeStage({ context: { include: ['previous_stage_tool_results'] } });
|
|
249
|
+
const agentCtx = getContextForStage(ctx, stage, 'nonexistent');
|
|
250
|
+
expect(agentCtx.previous_tool_results).toBeUndefined();
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe('getContextForStage — all_stage_tool_results', () => {
|
|
255
|
+
it('includes tool calls from all stages', () => {
|
|
256
|
+
const ctx = createInitialContext('test');
|
|
257
|
+
const tc1: ToolCall[] = [{ id: '1', name: 'tool-a', arguments: {}, result: 'r1' }];
|
|
258
|
+
const tc2: ToolCall[] = [{ id: '2', name: 'tool-b', arguments: {}, result: 'r2' }];
|
|
259
|
+
addStageToolResults(ctx, 'stage-1', tc1);
|
|
260
|
+
addStageToolResults(ctx, 'stage-2', tc2);
|
|
261
|
+
|
|
262
|
+
const stage = makeStage({ context: { include: ['all_stage_tool_results'] } });
|
|
263
|
+
const agentCtx = getContextForStage(ctx, stage);
|
|
264
|
+
|
|
265
|
+
expect(agentCtx.previous_tool_results).toEqual({ 'stage-1': tc1, 'stage-2': tc2 });
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { DirectEngineSpawner } from '../src/spawners/direct-engine-spawner.js';
|
|
3
|
+
import type { EngineConfig } from '../src/engine.js';
|
|
4
|
+
import type { PipelineRun } from '@studio-foundation/contracts';
|
|
5
|
+
|
|
6
|
+
function makeSuccessRun(overrides?: Partial<PipelineRun>): PipelineRun {
|
|
7
|
+
return {
|
|
8
|
+
id: 'child-run-1',
|
|
9
|
+
pipeline_name: 'test-pipe',
|
|
10
|
+
status: 'success',
|
|
11
|
+
started_at: new Date().toISOString(),
|
|
12
|
+
stages: [
|
|
13
|
+
{
|
|
14
|
+
id: 's1',
|
|
15
|
+
stage_name: 'final',
|
|
16
|
+
status: 'success',
|
|
17
|
+
started_at: new Date().toISOString(),
|
|
18
|
+
tasks: [],
|
|
19
|
+
output: { answer: 42 },
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// We mock PipelineEngine to avoid real execution
|
|
27
|
+
vi.mock('../src/engine.js', () => ({
|
|
28
|
+
PipelineEngine: vi.fn(function () {
|
|
29
|
+
return { run: vi.fn() };
|
|
30
|
+
}),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
describe('DirectEngineSpawner', () => {
|
|
34
|
+
it('calls child engine.run() with correct args', async () => {
|
|
35
|
+
const { PipelineEngine } = await import('../src/engine.js');
|
|
36
|
+
const mockRun = vi.fn().mockResolvedValue(makeSuccessRun());
|
|
37
|
+
(PipelineEngine as any).mockImplementation(function () { return { run: mockRun }; });
|
|
38
|
+
|
|
39
|
+
const spawner = new DirectEngineSpawner({} as EngineConfig);
|
|
40
|
+
await spawner.spawnAndWait({
|
|
41
|
+
pipeline: 'recipe-developer',
|
|
42
|
+
input: { dish: 'pasta' },
|
|
43
|
+
parentRunId: 'parent-1',
|
|
44
|
+
depth: 1,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(mockRun).toHaveBeenCalledWith(
|
|
48
|
+
expect.objectContaining({
|
|
49
|
+
pipeline: 'recipe-developer',
|
|
50
|
+
input: { dish: 'pasta' },
|
|
51
|
+
parentRunId: 'parent-1',
|
|
52
|
+
depth: 1,
|
|
53
|
+
})
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns run_id, status, and last stage output on success', async () => {
|
|
58
|
+
const { PipelineEngine } = await import('../src/engine.js');
|
|
59
|
+
const successRun = makeSuccessRun();
|
|
60
|
+
(PipelineEngine as any).mockImplementation(function () { return {
|
|
61
|
+
run: vi.fn().mockResolvedValue(successRun),
|
|
62
|
+
}; });
|
|
63
|
+
|
|
64
|
+
const spawner = new DirectEngineSpawner({} as EngineConfig);
|
|
65
|
+
const result = await spawner.spawnAndWait({
|
|
66
|
+
pipeline: 'test',
|
|
67
|
+
input: {},
|
|
68
|
+
parentRunId: 'p1',
|
|
69
|
+
depth: 1,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(result.run_id).toBe('child-run-1');
|
|
73
|
+
expect(result.status).toBe('success');
|
|
74
|
+
expect(result.output).toEqual({ answer: 42 });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('throws when child run fails', async () => {
|
|
78
|
+
const { PipelineEngine } = await import('../src/engine.js');
|
|
79
|
+
const failedRun = makeSuccessRun({ id: 'child-fail', status: 'failed', stages: [] });
|
|
80
|
+
(PipelineEngine as any).mockImplementation(function () { return {
|
|
81
|
+
run: vi.fn().mockResolvedValue(failedRun),
|
|
82
|
+
}; });
|
|
83
|
+
|
|
84
|
+
const spawner = new DirectEngineSpawner({} as EngineConfig);
|
|
85
|
+
await expect(
|
|
86
|
+
spawner.spawnAndWait({ pipeline: 'bad', input: {}, parentRunId: 'p1', depth: 1 })
|
|
87
|
+
).rejects.toThrow('Child run child-fail failed');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('throws when child run is rejected', async () => {
|
|
91
|
+
const { PipelineEngine } = await import('../src/engine.js');
|
|
92
|
+
const rejectedRun = makeSuccessRun({ id: 'child-rej', status: 'rejected', stages: [] });
|
|
93
|
+
(PipelineEngine as any).mockImplementation(function () { return {
|
|
94
|
+
run: vi.fn().mockResolvedValue(rejectedRun),
|
|
95
|
+
}; });
|
|
96
|
+
|
|
97
|
+
const spawner = new DirectEngineSpawner({} as EngineConfig);
|
|
98
|
+
await expect(
|
|
99
|
+
spawner.spawnAndWait({ pipeline: 'qa', input: {}, parentRunId: 'p1', depth: 1 })
|
|
100
|
+
).rejects.toThrow('Child run child-rej rejected');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Feature-builder E2E test
|
|
5
|
+
// "Add FAQ to About page" — this is THE test that must pass 10/10
|
|
6
|
+
//
|
|
7
|
+
// Requires:
|
|
8
|
+
// - API key (ANTHROPIC_API_KEY)
|
|
9
|
+
// - A test repo to modify
|
|
10
|
+
// - All pipeline infrastructure running
|
|
11
|
+
//
|
|
12
|
+
// Unskip when everything is fully connected
|
|
13
|
+
|
|
14
|
+
describe.skip('feature-builder E2E', () => {
|
|
15
|
+
const ROOT = join(import.meta.dirname, '..', '..');
|
|
16
|
+
|
|
17
|
+
it('should add FAQ section to About page', async () => {
|
|
18
|
+
const { PipelineEngine } = await import('../../src/engine.js');
|
|
19
|
+
const { InMemoryRunStore } = await import('../../src/state/run-store.js');
|
|
20
|
+
const { ToolRegistry, createRepoManagerTools, createShellTools, createSearchTools } = await import('@studio-foundation/runner');
|
|
21
|
+
const { createDefaultRegistry } = await import('@studio-foundation/runner');
|
|
22
|
+
|
|
23
|
+
// Setup tools
|
|
24
|
+
const toolRegistry = new ToolRegistry();
|
|
25
|
+
const testRepoPath = '/tmp/studio-test-repo'; // TODO: create temp repo
|
|
26
|
+
for (const tool of createRepoManagerTools(testRepoPath)) {
|
|
27
|
+
toolRegistry.register(tool);
|
|
28
|
+
}
|
|
29
|
+
for (const tool of createShellTools(testRepoPath)) {
|
|
30
|
+
toolRegistry.register(tool);
|
|
31
|
+
}
|
|
32
|
+
for (const tool of createSearchTools(testRepoPath)) {
|
|
33
|
+
toolRegistry.register(tool);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const providerRegistry = createDefaultRegistry({
|
|
37
|
+
anthropic: { apiKey: process.env.ANTHROPIC_API_KEY! },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const engine = new PipelineEngine({
|
|
41
|
+
configsDir: join(ROOT, 'configs'),
|
|
42
|
+
repoPath: testRepoPath,
|
|
43
|
+
providerRegistry,
|
|
44
|
+
toolRegistry,
|
|
45
|
+
db: new InMemoryRunStore(),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const result = await engine.run({
|
|
49
|
+
pipeline: 'software/feature-builder',
|
|
50
|
+
input: 'Add a FAQ section to the About page',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(result.status).toBe('success');
|
|
54
|
+
expect(result.stages).toHaveLength(4);
|
|
55
|
+
expect(result.stages.every(s => s.status === 'success')).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { StageRetryEvent, StagedToolCallStartEvent, StagedToolCallCompleteEvent } from '../src/events.js';
|
|
3
|
+
|
|
4
|
+
describe('StageRetryEvent', () => {
|
|
5
|
+
it('includes max_attempts field', () => {
|
|
6
|
+
const event: StageRetryEvent = {
|
|
7
|
+
stage: 'code-generation',
|
|
8
|
+
attempt: 2,
|
|
9
|
+
failures: ['missing field: summary'],
|
|
10
|
+
max_attempts: 3,
|
|
11
|
+
};
|
|
12
|
+
expect(event.max_attempts).toBe(3);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('includes optional agent_output_raw and tool_calls_count', () => {
|
|
16
|
+
const event: StageRetryEvent = {
|
|
17
|
+
stage: 'code-generation',
|
|
18
|
+
attempt: 1,
|
|
19
|
+
failures: ['validation failed'],
|
|
20
|
+
max_attempts: 5,
|
|
21
|
+
agent_output_raw: '{"summary": "incomplete"}',
|
|
22
|
+
tool_calls_count: 3,
|
|
23
|
+
};
|
|
24
|
+
expect(event.agent_output_raw).toBe('{"summary": "incomplete"}');
|
|
25
|
+
expect(event.tool_calls_count).toBe(3);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('StagedToolCallStartEvent', () => {
|
|
30
|
+
it('includes stage, tool, and params fields', () => {
|
|
31
|
+
const event: StagedToolCallStartEvent = {
|
|
32
|
+
stage: 'code-generation',
|
|
33
|
+
tool: 'repo_manager-write_file',
|
|
34
|
+
params: { path: 'src/foo.ts' },
|
|
35
|
+
timestamp: 1700000000000,
|
|
36
|
+
};
|
|
37
|
+
expect(event.stage).toBe('code-generation');
|
|
38
|
+
expect(event.tool).toBe('repo_manager-write_file');
|
|
39
|
+
expect(event.params).toEqual({ path: 'src/foo.ts' });
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('StagedToolCallCompleteEvent', () => {
|
|
44
|
+
it('includes stage, tool, result, and duration_ms fields', () => {
|
|
45
|
+
const event: StagedToolCallCompleteEvent = {
|
|
46
|
+
stage: 'code-generation',
|
|
47
|
+
tool: 'repo_manager-write_file',
|
|
48
|
+
result: 'written',
|
|
49
|
+
duration_ms: 120,
|
|
50
|
+
timestamp: 1700000000000,
|
|
51
|
+
};
|
|
52
|
+
expect(event.stage).toBe('code-generation');
|
|
53
|
+
expect(event.tool).toBe('repo_manager-write_file');
|
|
54
|
+
expect(event.duration_ms).toBe(120);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
|
|
2
|
+
name: group-test
|
|
3
|
+
description: Test pipeline with a feedback loop group
|
|
4
|
+
version: 2
|
|
5
|
+
stages:
|
|
6
|
+
- name: analysis
|
|
7
|
+
kind: analysis
|
|
8
|
+
agent: test-agent
|
|
9
|
+
ralph:
|
|
10
|
+
max_attempts: 1
|
|
11
|
+
retry_strategy: none
|
|
12
|
+
context:
|
|
13
|
+
include:
|
|
14
|
+
- input
|
|
15
|
+
- group: impl-review
|
|
16
|
+
max_iterations: 3
|
|
17
|
+
stages:
|
|
18
|
+
- name: code-gen
|
|
19
|
+
kind: code_generation
|
|
20
|
+
agent: test-agent
|
|
21
|
+
contract: code-gen
|
|
22
|
+
ralph:
|
|
23
|
+
max_attempts: 1
|
|
24
|
+
retry_strategy: none
|
|
25
|
+
context:
|
|
26
|
+
include:
|
|
27
|
+
- input
|
|
28
|
+
- all_stage_outputs
|
|
29
|
+
- group_feedback
|
|
30
|
+
- name: qa-review
|
|
31
|
+
kind: qa
|
|
32
|
+
agent: test-agent
|
|
33
|
+
contract: qa-gate
|
|
34
|
+
ralph:
|
|
35
|
+
max_attempts: 1
|
|
36
|
+
retry_strategy: none
|
|
37
|
+
context:
|
|
38
|
+
include:
|
|
39
|
+
- input
|
|
40
|
+
- all_stage_outputs
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
|
|
2
|
+
name: two-stage
|
|
3
|
+
description: Two stage pipeline
|
|
4
|
+
version: 1
|
|
5
|
+
stages:
|
|
6
|
+
- name: stage-1
|
|
7
|
+
kind: analysis
|
|
8
|
+
agent: test-agent
|
|
9
|
+
ralph:
|
|
10
|
+
max_attempts: 1
|
|
11
|
+
retry_strategy: none
|
|
12
|
+
context:
|
|
13
|
+
include:
|
|
14
|
+
- input
|
|
15
|
+
- name: stage-2
|
|
16
|
+
kind: planning
|
|
17
|
+
agent: test-agent
|
|
18
|
+
ralph:
|
|
19
|
+
max_attempts: 1
|
|
20
|
+
retry_strategy: none
|
|
21
|
+
context:
|
|
22
|
+
include:
|
|
23
|
+
- input
|
|
24
|
+
- previous_stage_output
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
name: feature-builder
|
|
2
|
+
description: Build a feature from a user description
|
|
3
|
+
version: 2
|
|
4
|
+
|
|
5
|
+
repo:
|
|
6
|
+
url: https://github.com/arianeguay/pipelines-test-repo
|
|
7
|
+
branch: main
|
|
8
|
+
|
|
9
|
+
stages:
|
|
10
|
+
- name: brief-analysis
|
|
11
|
+
kind: analysis
|
|
12
|
+
agent: analyst
|
|
13
|
+
contract: brief-analysis
|
|
14
|
+
ralph:
|
|
15
|
+
max_attempts: 3
|
|
16
|
+
retry_strategy: exponential
|
|
17
|
+
max_tool_calls: 5
|
|
18
|
+
context:
|
|
19
|
+
include:
|
|
20
|
+
- input
|
|
21
|
+
|
|
22
|
+
- name: implementation-plan
|
|
23
|
+
kind: planning
|
|
24
|
+
agent: analyst
|
|
25
|
+
contract: implementation-plan
|
|
26
|
+
ralph:
|
|
27
|
+
max_attempts: 3
|
|
28
|
+
retry_strategy: exponential
|
|
29
|
+
max_tool_calls: 5
|
|
30
|
+
context:
|
|
31
|
+
include:
|
|
32
|
+
- input
|
|
33
|
+
- previous_stage_output
|
|
34
|
+
|
|
35
|
+
- group: implementation-review
|
|
36
|
+
max_iterations: 3
|
|
37
|
+
stages:
|
|
38
|
+
- name: code-generation
|
|
39
|
+
kind: code_generation
|
|
40
|
+
agent: coder
|
|
41
|
+
contract: code-generation
|
|
42
|
+
hooks:
|
|
43
|
+
on_stage_complete:
|
|
44
|
+
- command: "npx tsc --noEmit"
|
|
45
|
+
on_failure: reject
|
|
46
|
+
- command: "npx eslint --rule 'no-empty: error' --rule 'no-unused-vars: warn' {{output.files_changed}}"
|
|
47
|
+
on_failure: reject
|
|
48
|
+
ralph:
|
|
49
|
+
max_attempts: 5
|
|
50
|
+
retry_strategy: exponential
|
|
51
|
+
max_tool_calls: 200
|
|
52
|
+
tools:
|
|
53
|
+
required:
|
|
54
|
+
- repo_manager-write_file
|
|
55
|
+
context:
|
|
56
|
+
include:
|
|
57
|
+
- input
|
|
58
|
+
- all_stage_outputs
|
|
59
|
+
- repo_files
|
|
60
|
+
- group_feedback
|
|
61
|
+
packs:
|
|
62
|
+
- example-conventions
|
|
63
|
+
|
|
64
|
+
- name: qa-review
|
|
65
|
+
kind: qa
|
|
66
|
+
agent: analyst
|
|
67
|
+
contract: qa-review
|
|
68
|
+
ralph:
|
|
69
|
+
max_attempts: 3
|
|
70
|
+
retry_strategy: exponential
|
|
71
|
+
max_tool_calls: 5
|
|
72
|
+
context:
|
|
73
|
+
include:
|
|
74
|
+
- input
|
|
75
|
+
- all_stage_outputs
|