devin-workflow
Version:
~Devin AI workflow automation
622 lines (543 loc) • 25.6 kB
JavaScript
import {HandoffManager} from '../src/handoff-manager.js';
import {
describe,
it,
expect,
} from '@jest/globals';
// Mock DevinClient for testing
class MockDevinClient {
constructor() {
this.sessions = [];
}
async createSession(prompt, playbook, title) {
this.sessions.push({
prompt,
playbook,
title,
});
// Simulate a session id and always succeed
return {
success: true,
session_id: `session-${this.sessions.length}`,
};
}
async getSession(sessionId) {
// Simulate a session with a last_devin_message
return {
message_count: 2,
raw_response: {
messages: [
{message: `Result for ${sessionId}`}, {message: 'sleep'}],
},
status: 'completed',
status_enum: 'COMPLETED',
};
}
isSessionCompleted(status, status_enum) {
return status === 'completed' || status_enum === 'COMPLETED';
}
}
describe('HandoffManager', () => {
it('should inject previous handoff data into the next step when RelyPreviousStep is true', async () => {
const devinClient = new MockDevinClient();
const manager = new HandoffManager(devinClient);
manager.setPollingInterval(1); // fast
manager.setFirstPollingInterval(1); // fast
manager.setTimeout(1000);
const steps = [
{
step_number: 1,
prompt: 'Create a ticket',
handoff: 'Provide ticket ID, work item type, and confirm successful data extraction with attachment count and linked items summary',
rely_previous_step: false,
playbook: null,
title: 'Step 1',
}, {
step_number: 2,
prompt: 'Process the ticket',
handoff: 'Provide processing result',
rely_previous_step: true,
playbook: null,
title: 'Step 2',
}];
const results = await manager.executeWorkflow(steps);
expect(results.length).toBe(2);
// Step 2's prompt should contain the expected sections in order
const step2Prompt = devinClient.sessions[1].prompt;
expect(step2Prompt).toContain('- Review and acknowledge the provided context from the previous step:');
expect(step2Prompt).toContain('Result for session-1');
expect(step2Prompt).toContain('Process the ticket');
expect(step2Prompt).toContain('## Expected Output and Requirements');
expect(step2Prompt).toContain('- Execute the tasks on the provided Tasks section systematically in the specified order');
expect(step2Prompt).toContain('- Brief the achieved result that includes: \n Provide processing result');
expect(step2Prompt).toContain('- Lastly, when all of tasks has been executed, type exactly the word "sleep" (without quotes) to indicate completion.It must be a standalone message.');
// Ensure order: Expected Output (with completion), Tasks (with acknowledge), Main Prompt
const idxExpectedOutput = step2Prompt.indexOf('## Expected Output and Requirements');
const idxLastly = step2Prompt.indexOf('- Lastly, when all of tasks has been executed');
const idxTasks = step2Prompt.indexOf('## Tasks');
const idxAcknowledge = step2Prompt.indexOf('- Review and acknowledge the provided context from the previous step:');
const idxMainPrompt = step2Prompt.indexOf('Process the ticket');
expect(idxExpectedOutput).toBeLessThan(idxLastly);
expect(idxLastly).toBeLessThan(idxTasks);
expect(idxTasks).toBeLessThan(idxAcknowledge);
expect(idxAcknowledge).toBeLessThan(idxMainPrompt);
});
it('should not inject previous handoff data if RelyPreviousStep is false', async () => {
const devinClient = new MockDevinClient();
const manager = new HandoffManager(devinClient);
manager.setPollingInterval(1);
manager.setFirstPollingInterval(1);
manager.setTimeout(1000);
const steps = [
{
step_number: 1,
prompt: 'Create a ticket',
handoff: 'Provide ticket ID, work item type, and confirm successful data extraction with attachment count and linked items summary',
rely_previous_step: false,
playbook: null,
title: 'Step 1',
}, {
step_number: 2,
prompt: 'Process the ticket',
handoff: 'Provide processing result',
rely_previous_step: false,
playbook: null,
title: 'Step 2',
}];
await manager.executeWorkflow(steps);
const step2Prompt = devinClient.sessions[1].prompt;
expect(step2Prompt).not.toContain('- Review and acknowledge the provided context from the previous step:');
});
it('should use previous main result if no handoff data is available', async () => {
const devinClient = new MockDevinClient();
const manager = new HandoffManager(devinClient);
manager.setPollingInterval(1);
manager.setFirstPollingInterval(1);
manager.setTimeout(1000);
// Remove handoff from step 1
const steps = [
{
step_number: 1,
prompt: 'Create a ticket',
handoff: null,
rely_previous_step: false,
playbook: null,
title: 'Step 1',
}, {
step_number: 2,
prompt: 'Process the ticket',
handoff: 'Provide processing result',
rely_previous_step: true,
playbook: null,
title: 'Step 2',
}];
await manager.executeWorkflow(steps);
const step2Prompt = devinClient.sessions[1].prompt;
expect(step2Prompt).toContain('- Review and acknowledge the provided context from the previous step:');
expect(step2Prompt).toContain('Result for session-1');
expect(step2Prompt).toContain('Process the ticket');
expect(step2Prompt).toContain('## Expected Output and Requirements');
expect(step2Prompt).toContain('- Execute the tasks on the provided Tasks section systematically in the specified order');
expect(step2Prompt).toContain('- Brief the achieved result that includes: \n Provide processing result');
expect(step2Prompt).toContain('- Lastly, when all of tasks has been executed, type exactly the word "sleep" (without quotes) to indicate completion.It must be a standalone message.');
// Ensure order: Expected Output (with completion), Tasks (with acknowledge), Main Prompt
const idxExpectedOutput = step2Prompt.indexOf('## Expected Output and Requirements');
const idxLastly = step2Prompt.indexOf('- Lastly, when all of tasks has been executed');
const idxTasks = step2Prompt.indexOf('## Tasks');
const idxAcknowledge = step2Prompt.indexOf('- Review and acknowledge the provided context from the previous step:');
const idxMainPrompt = step2Prompt.indexOf('Process the ticket');
expect(idxExpectedOutput).toBeLessThan(idxLastly);
expect(idxLastly).toBeLessThan(idxTasks);
expect(idxTasks).toBeLessThan(idxAcknowledge);
expect(idxAcknowledge).toBeLessThan(idxMainPrompt);
});
it('should inject repo information before Tasks section when repo is present', async () => {
const devinClient = new MockDevinClient();
const manager = new HandoffManager(devinClient);
manager.setPollingInterval(1);
manager.setFirstPollingInterval(1);
manager.setTimeout(1000);
const steps = [
{
step_number: 1,
prompt: 'Create a ticket',
handoff: 'Provide ticket ID and work item type',
rely_previous_step: false,
playbook: null,
title: 'Step 1',
repo: 'dev.azure.com/access-devops/Access%20Vincere/_git/staffingboss-reactapp'
}
];
await manager.executeWorkflow(steps);
const step1Prompt = devinClient.sessions[0].prompt;
// Verify repo line is present and correctly positioned
expect(step1Prompt).toContain('- Repo: dev.azure.com/access-devops/Access%20Vincere/_git/staffingboss-reactapp');
// Verify Expected Output comes before Tasks, and repo line comes before Tasks section
const expectedOutputIndex = step1Prompt.indexOf('## Expected Output and Requirements');
const tasksIndex = step1Prompt.indexOf('## Tasks');
const repoIndex = step1Prompt.indexOf('- Repo:');
expect(expectedOutputIndex).toBeLessThan(tasksIndex);
expect(repoIndex).toBeLessThan(tasksIndex);
// Verify the exact format
expect(step1Prompt).toContain('- Repo: dev.azure.com/access-devops/Access%20Vincere/_git/staffingboss-reactapp\n\n## Tasks');
});
it('should not inject repo information when repo is null or undefined', async () => {
const devinClient = new MockDevinClient();
const manager = new HandoffManager(devinClient);
manager.setPollingInterval(1);
manager.setFirstPollingInterval(1);
manager.setTimeout(1000);
const steps = [
{
step_number: 1,
prompt: 'Create a ticket',
handoff: 'Provide ticket ID and work item type',
rely_previous_step: false,
playbook: null,
title: 'Step 1',
repo: null
},
{
step_number: 2,
prompt: 'Process ticket',
handoff: 'Provide processing result',
rely_previous_step: false,
playbook: null,
title: 'Step 2'
// repo is undefined
}
];
await manager.executeWorkflow(steps);
const step1Prompt = devinClient.sessions[0].prompt;
const step2Prompt = devinClient.sessions[1].prompt;
// Verify no repo line is present in either step
expect(step1Prompt).not.toContain('- Repo:');
expect(step2Prompt).not.toContain('- Repo:');
// Verify Expected Output comes before Tasks section
expect(step1Prompt).toContain('## Expected Output and Requirements');
expect(step1Prompt).toContain('## Tasks');
expect(step2Prompt).toContain('## Expected Output and Requirements');
expect(step2Prompt).toContain('## Tasks');
});
it('should handle repo with previous step data injection correctly', async () => {
const devinClient = new MockDevinClient();
const manager = new HandoffManager(devinClient);
manager.setPollingInterval(1);
manager.setFirstPollingInterval(1);
manager.setTimeout(1000);
const steps = [
{
step_number: 1,
prompt: 'Create a ticket',
handoff: 'Provide ticket ID and work item type',
rely_previous_step: false,
playbook: null,
title: 'Step 1'
},
{
step_number: 2,
prompt: 'Process the ticket',
handoff: 'Provide processing result',
rely_previous_step: true,
playbook: null,
title: 'Step 2',
repo: 'example-repo' // Use shortcut that will be expanded
}
];
await manager.executeWorkflow(steps);
const step2Prompt = devinClient.sessions[1].prompt;
// Verify repo line is present and positioned correctly (expanded version)
expect(step2Prompt).toContain('- Repo: dev.azure.com/access-devops/Access%20Vincere/_git/example-repo');
// Verify the order: Expected Output, then repo before Tasks, then Tasks with previous data
const expectedOutputIndex = step2Prompt.indexOf('## Expected Output and Requirements');
const repoIndex = step2Prompt.indexOf('- Repo:');
const tasksIndex = step2Prompt.indexOf('## Tasks');
const acknowledgeIndex = step2Prompt.indexOf('- Review and acknowledge the provided context from the previous step:');
expect(expectedOutputIndex).toBeLessThan(repoIndex);
expect(repoIndex).toBeLessThan(tasksIndex);
expect(tasksIndex).toBeLessThan(acknowledgeIndex);
// Verify both repo and previous data are present
expect(step2Prompt).toContain('- Repo: dev.azure.com/access-devops/Access%20Vincere/_git/example-repo');
expect(step2Prompt).toContain('- Review and acknowledge the provided context from the previous step:');
expect(step2Prompt).toContain('Result for session-1');
});
it('should expand shortcut repo names to full Azure DevOps URLs', async () => {
const devinClient = new MockDevinClient();
const manager = new HandoffManager(devinClient);
manager.setPollingInterval(1);
manager.setFirstPollingInterval(1);
manager.setTimeout(1000);
const steps = [
{
step_number: 1,
prompt: 'Setup repository',
handoff: 'Repository ready',
rely_previous_step: false,
playbook: null,
title: 'Step 1',
repo: 'staffingboss-reactapp' // shortcut version
}
];
await manager.executeWorkflow(steps);
const step1Prompt = devinClient.sessions[0].prompt;
// Verify shortcut is expanded to full URL
expect(step1Prompt).toContain('- Repo: dev.azure.com/access-devops/Access%20Vincere/_git/staffingboss-reactapp');
expect(step1Prompt).not.toContain('- Repo: staffingboss-reactapp\n');
});
it('should keep full repo URLs unchanged', async () => {
const devinClient = new MockDevinClient();
const manager = new HandoffManager(devinClient);
manager.setPollingInterval(1);
manager.setFirstPollingInterval(1);
manager.setTimeout(1000);
const steps = [
{
step_number: 1,
prompt: 'Setup repository',
handoff: 'Repository ready',
rely_previous_step: false,
playbook: null,
title: 'Step 1',
repo: 'dev.azure.com/access-devops/Access%20Vincere/_git/staffingboss-reactapp' // full version
}
];
await manager.executeWorkflow(steps);
const step1Prompt = devinClient.sessions[0].prompt;
// Verify full URL remains unchanged
expect(step1Prompt).toContain('- Repo: dev.azure.com/access-devops/Access%20Vincere/_git/staffingboss-reactapp');
});
it('should handle different shortcut repo names correctly', async () => {
const devinClient = new MockDevinClient();
const manager = new HandoffManager(devinClient);
manager.setPollingInterval(1);
manager.setFirstPollingInterval(1);
manager.setTimeout(1000);
const steps = [
{
step_number: 1,
prompt: 'Setup first repo',
handoff: 'First repo ready',
rely_previous_step: false,
playbook: null,
title: 'Step 1',
repo: 'my-frontend-app'
},
{
step_number: 2,
prompt: 'Setup second repo',
handoff: 'Second repo ready',
rely_previous_step: false,
playbook: null,
title: 'Step 2',
repo: 'backend-api'
}
];
await manager.executeWorkflow(steps);
const step1Prompt = devinClient.sessions[0].prompt;
const step2Prompt = devinClient.sessions[1].prompt;
// Verify both shortcuts are expanded correctly
expect(step1Prompt).toContain('- Repo: dev.azure.com/access-devops/Access%20Vincere/_git/my-frontend-app');
expect(step2Prompt).toContain('- Repo: dev.azure.com/access-devops/Access%20Vincere/_git/backend-api');
});
it('should handle external URLs without modification', async () => {
const devinClient = new MockDevinClient();
const manager = new HandoffManager(devinClient);
manager.setPollingInterval(1);
manager.setFirstPollingInterval(1);
manager.setTimeout(1000);
const steps = [
{
step_number: 1,
prompt: 'Setup external repo',
handoff: 'External repo ready',
rely_previous_step: false,
playbook: null,
title: 'Step 1',
repo: 'https://github.com/example/project'
}
];
await manager.executeWorkflow(steps);
const step1Prompt = devinClient.sessions[0].prompt;
// Verify external URL remains unchanged
expect(step1Prompt).toContain('- Repo: https://github.com/example/project');
});
it('should inherit repo from step 1 to subsequent steps when repo is null', async () => {
const devinClient = new MockDevinClient();
const manager = new HandoffManager(devinClient);
manager.setPollingInterval(1);
manager.setFirstPollingInterval(1);
manager.setTimeout(1000);
const steps = [
{
step_number: 1,
prompt: 'Setup repository',
handoff: 'Repository ready',
rely_previous_step: false,
playbook: null,
title: 'Step 1',
repo: 'staffingboss-reactapp' // Step 1 defines repo
},
{
step_number: 2,
prompt: 'Process code',
handoff: 'Code processed',
rely_previous_step: false,
playbook: null,
title: 'Step 2',
repo: null // Step 2 should inherit repo
},
{
step_number: 3,
prompt: 'Deploy code',
handoff: 'Code deployed',
rely_previous_step: false,
playbook: null,
title: 'Step 3',
repo: null // Step 3 should also inherit repo
}
];
await manager.executeWorkflow(steps);
const step1Prompt = devinClient.sessions[0].prompt;
const step2Prompt = devinClient.sessions[1].prompt;
const step3Prompt = devinClient.sessions[2].prompt;
// Step 1: Original repo
expect(step1Prompt).toContain('- Repo: dev.azure.com/access-devops/Access%20Vincere/_git/staffingboss-reactapp');
// Step 2: Inherited repo
expect(step2Prompt).toContain('- Repo: dev.azure.com/access-devops/Access%20Vincere/_git/staffingboss-reactapp');
// Step 3: Inherited repo
expect(step3Prompt).toContain('- Repo: dev.azure.com/access-devops/Access%20Vincere/_git/staffingboss-reactapp');
});
it('should stop repo inheritance when step explicitly sets repo to "none"', async () => {
const devinClient = new MockDevinClient();
const manager = new HandoffManager(devinClient);
manager.setPollingInterval(1);
manager.setFirstPollingInterval(1);
manager.setTimeout(1000);
const steps = [
{
step_number: 1,
prompt: 'Setup repository',
handoff: 'Repository ready',
rely_previous_step: false,
playbook: null,
title: 'Step 1',
repo: 'staffingboss-reactapp' // Step 1 defines repo
},
{
step_number: 2,
prompt: 'Process without repo',
handoff: 'Processing done',
rely_previous_step: false,
playbook: null,
title: 'Step 2',
repo: 'none' // Step 2 explicitly sets to none
},
{
step_number: 3,
prompt: 'Continue processing',
handoff: 'Continue done',
rely_previous_step: false,
playbook: null,
title: 'Step 3',
repo: null // Step 3 should not inherit (inheritance stopped)
}
];
await manager.executeWorkflow(steps);
const step1Prompt = devinClient.sessions[0].prompt;
const step2Prompt = devinClient.sessions[1].prompt;
const step3Prompt = devinClient.sessions[2].prompt;
// Step 1: Original repo
expect(step1Prompt).toContain('- Repo: dev.azure.com/access-devops/Access%20Vincere/_git/staffingboss-reactapp');
// Step 2: No repo (explicitly set to none) - but still has Expected Output section first
expect(step2Prompt).not.toContain('- Repo:');
expect(step2Prompt).toContain('## Expected Output and Requirements');
expect(step2Prompt).toContain('## Tasks');
// Step 3: No repo (inheritance stopped) - but still has Expected Output section first
expect(step3Prompt).not.toContain('- Repo:');
expect(step3Prompt).toContain('## Expected Output and Requirements');
expect(step3Prompt).toContain('## Tasks');
});
it('should update inheritance when step defines new repo', async () => {
const devinClient = new MockDevinClient();
const manager = new HandoffManager(devinClient);
manager.setPollingInterval(1);
manager.setFirstPollingInterval(1);
manager.setTimeout(1000);
const steps = [
{
step_number: 1,
prompt: 'Setup first repo',
handoff: 'First repo ready',
rely_previous_step: false,
playbook: null,
title: 'Step 1',
repo: 'first-repo' // Step 1 defines first repo
},
{
step_number: 2,
prompt: 'Switch to second repo',
handoff: 'Second repo ready',
rely_previous_step: false,
playbook: null,
title: 'Step 2',
repo: 'second-repo' // Step 2 defines new repo
},
{
step_number: 3,
prompt: 'Continue with current repo',
handoff: 'Work done',
rely_previous_step: false,
playbook: null,
title: 'Step 3',
repo: null // Step 3 should inherit second repo
}
];
await manager.executeWorkflow(steps);
const step1Prompt = devinClient.sessions[0].prompt;
const step2Prompt = devinClient.sessions[1].prompt;
const step3Prompt = devinClient.sessions[2].prompt;
// Step 1: First repo
expect(step1Prompt).toContain('- Repo: dev.azure.com/access-devops/Access%20Vincere/_git/first-repo');
// Step 2: Second repo
expect(step2Prompt).toContain('- Repo: dev.azure.com/access-devops/Access%20Vincere/_git/second-repo');
// Step 3: Inherited second repo
expect(step3Prompt).toContain('- Repo: dev.azure.com/access-devops/Access%20Vincere/_git/second-repo');
});
it('should not inherit repo when step 1 has no repo defined', async () => {
const devinClient = new MockDevinClient();
const manager = new HandoffManager(devinClient);
manager.setPollingInterval(1);
manager.setFirstPollingInterval(1);
manager.setTimeout(1000);
const steps = [
{
step_number: 1,
prompt: 'Initial setup',
handoff: 'Setup done',
rely_previous_step: false,
playbook: null,
title: 'Step 1',
repo: null // Step 1 has no repo
},
{
step_number: 2,
prompt: 'Continue work',
handoff: 'Work done',
rely_previous_step: false,
playbook: null,
title: 'Step 2',
repo: null // Step 2 should not inherit anything
}
];
await manager.executeWorkflow(steps);
const step1Prompt = devinClient.sessions[0].prompt;
const step2Prompt = devinClient.sessions[1].prompt;
// Both steps should not have repo but still have proper structure
expect(step1Prompt).not.toContain('- Repo:');
expect(step1Prompt).toContain('## Expected Output and Requirements');
expect(step1Prompt).toContain('## Tasks');
expect(step2Prompt).not.toContain('- Repo:');
expect(step2Prompt).toContain('## Expected Output and Requirements');
expect(step2Prompt).toContain('## Tasks');
});
});