Add unit tests for actions with valid and empty args
- Add a test case for applying an action with valid args. - Add a test case for applying an action with empty args.
This commit is contained in:
parent
dc96991fca
commit
d99548b3a0
@ -1,25 +1,47 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { UiUtilWrapper } from '../util/uiUtil';
|
||||
import { Action, CustomActions } from './customAction';
|
||||
|
||||
import { createTempSubdirectory, runCommandAndWriteOutput } from '../util/commonUtil';
|
||||
import { CommandResult } from '../util/commonUtil';
|
||||
import { logger } from '../util/logger';
|
||||
|
||||
|
||||
export interface ChatAction {
|
||||
name: string;
|
||||
type: string[];
|
||||
|
||||
|
||||
// extend Action
|
||||
export class CommandRunAction implements Action {
|
||||
name: string;
|
||||
description: string;
|
||||
type: string[];
|
||||
action: string;
|
||||
handler: (codeBlock: { [key: string]: string }) => Promise<void>;
|
||||
}
|
||||
handler: string[];
|
||||
args: { "name": string, "description": string, "type": string, "as"?: string, "from": string }[];
|
||||
|
||||
constructor() {
|
||||
this.name = 'command_run';
|
||||
this.description = 'run command';
|
||||
this.type = ['command'];
|
||||
this.action = 'command_run';
|
||||
this.handler = [];
|
||||
this.args = [
|
||||
{"name": "content", "description": "command json to run", "type": "string", "from": "content.content"},
|
||||
];
|
||||
}
|
||||
|
||||
async handlerAction(args: {[key: string]: any}): Promise<CommandResult> {
|
||||
try {
|
||||
const commandData = JSON.parse(args.content);
|
||||
const result = await ActionManager.getInstance().applyCommandAction(commandData.command, commandData.args);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.channel()?.error('Failed to parse code file content: ' + error);
|
||||
logger.channel()?.show();
|
||||
return {exitCode: -1, stdout: '', stderr: `Failed to parse code file content: ${error}`};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default class ActionManager {
|
||||
private static instance: ActionManager;
|
||||
private actions: ChatAction[] = [];
|
||||
private actions: Action[] = [];
|
||||
|
||||
private constructor() { }
|
||||
|
||||
@ -27,23 +49,106 @@ export default class ActionManager {
|
||||
if (!ActionManager.instance) {
|
||||
ActionManager.instance = new ActionManager();
|
||||
}
|
||||
|
||||
ActionManager.instance.registerAction(new CommandRunAction());
|
||||
return ActionManager.instance;
|
||||
}
|
||||
|
||||
public registerAction(action: ChatAction): void {
|
||||
public registerAction(action: Action): void {
|
||||
this.actions.push(action);
|
||||
}
|
||||
|
||||
public getActionList(): ChatAction[] {
|
||||
public getActionList(): Action[] {
|
||||
return this.actions;
|
||||
}
|
||||
|
||||
public async applyAction(actionName: string, codeBlock: { [key: string]: string }): Promise<void> {
|
||||
public async applyAction(actionName: string, content: { "command": string, content: string, fileName: string }): Promise<CommandResult> {
|
||||
const action = this.actions.find(action => action.name.trim() === actionName.trim());
|
||||
if (action) {
|
||||
logger.channel()?.info(`Applying action: ${actionName}`);
|
||||
await action.handler(codeBlock);
|
||||
if (!action) {
|
||||
logger.channel()?.info(`Action not found: ${actionName}`);
|
||||
return {exitCode: -1, stdout: '', stderr: `${actionName} not found in action list: ${this.actions.map(action => action.name)}`};
|
||||
}
|
||||
|
||||
logger.channel()?.info(`Apply action: ${actionName}`);
|
||||
|
||||
// action.args define what args should be passed to handler
|
||||
// for example:
|
||||
// action.args = [
|
||||
// {"name": "arg1", "description": "arg1 description", "type": "string", "from": "content.fileName"},
|
||||
// {"name": "arg2", "description": "arg2 description", "type": "string", "from": "content.content.v1"}]
|
||||
// then:
|
||||
// arg1 = content.fileName
|
||||
// arg2 = content.content.v1
|
||||
// before use content.content.v1, we should parse content.content first as json
|
||||
|
||||
if (action.args === undefined || action.args.length === 0) {
|
||||
// every action should have args, if not, then it is invalid
|
||||
logger.channel()?.error(`Action ${actionName} has no args`);
|
||||
logger.channel()?.show();
|
||||
return {exitCode: -1, stdout: '', stderr: `Action ${actionName} has no args`};
|
||||
}
|
||||
|
||||
// construct args for handler
|
||||
let args: {[key: string]: any} = {};
|
||||
|
||||
// check whether action.args has x.x.x like from value
|
||||
let hasDotDotFrom = false;
|
||||
for (const arg of action.args) {
|
||||
if (arg.from !== undefined) {
|
||||
// if arg.from has two or more dot, then it is x.x.x
|
||||
if (arg.from.split('.').length >= 3) {
|
||||
hasDotDotFrom = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if hasDotDotFrom is true, then parse content as json
|
||||
if (hasDotDotFrom) {
|
||||
try {
|
||||
content.content = JSON.parse(content.content);
|
||||
} catch (error) {
|
||||
logger.channel()?.info(`Parse content as json failed: ${error}`);
|
||||
return {exitCode: -1, stdout: '', stderr: `Parse content as json failed: ${error}`};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
for (const arg of action.args) {
|
||||
let argValue = '';
|
||||
if (arg.from !== undefined) {
|
||||
// visit arg.from, it is string
|
||||
let argFromValue: any = content;
|
||||
const argFrom = arg.from.split('.');
|
||||
// first item of argFrom is content, so skip it
|
||||
for (const argFromItem of argFrom.slice(1)) {
|
||||
argFromValue = argFromValue[argFromItem];
|
||||
}
|
||||
// if argFromValue is undefined, then it is invalid
|
||||
if (argFromValue === undefined) {
|
||||
logger.channel()?.error(`Action ${actionName} arg ${arg.name} from ${arg.from} is undefined`);
|
||||
logger.channel()?.show();
|
||||
return {exitCode: -1, stdout: '', stderr: `Action ${actionName} arg ${arg.name} from ${arg.from} is undefined`};
|
||||
}
|
||||
argValue = argFromValue;
|
||||
}
|
||||
args[arg.name] = argValue;
|
||||
}
|
||||
|
||||
return await action.handlerAction(args);
|
||||
}
|
||||
|
||||
public async applyCommandAction(command: string, args: {[key: string]: any}) : Promise<CommandResult> {
|
||||
const action = this.actions.find(action => action.name.trim() === command.trim());
|
||||
if (!action) {
|
||||
logger.channel()?.info(`Action not found: ${command}`);
|
||||
return {exitCode: -1, stdout: '', stderr: `${command} not found in action list: ${this.actions.map(action => action.name)}`};
|
||||
}
|
||||
|
||||
logger.channel()?.info(`Apply command action: ${command}`);
|
||||
|
||||
return await action.handlerAction(args);
|
||||
}
|
||||
|
||||
public loadCustomActions(workflowsDir: string): void {
|
||||
@ -51,40 +156,7 @@ export default class ActionManager {
|
||||
customActionsInstance.parseActions(workflowsDir);
|
||||
|
||||
for (const customAction of customActionsInstance.getActions()) {
|
||||
const chatAction: ChatAction = {
|
||||
name: customAction.name,
|
||||
type: customAction.type,
|
||||
description: customAction.description,
|
||||
action: customAction.action,
|
||||
handler: async (codeBlock: { [key: string]: string }) => {
|
||||
// Implement the handler logic for the custom action
|
||||
const tempDir = await createTempSubdirectory('devchat/context');
|
||||
const tempFile = path.join(tempDir, 'apply.json');
|
||||
|
||||
const contextMap = {
|
||||
'codeBlock': codeBlock,
|
||||
'workspaceDir': UiUtilWrapper.workspaceFoldersFirstPath(),
|
||||
'activeFile': UiUtilWrapper.activeFilePath(),
|
||||
'selectRang': UiUtilWrapper.selectRange(),
|
||||
'secectText': UiUtilWrapper.selectText(),
|
||||
};
|
||||
|
||||
// Save contextMap to temp file
|
||||
await UiUtilWrapper.writeFile(tempFile, JSON.stringify(contextMap));
|
||||
// replace ${contextFile} with tempFile for arg in handler
|
||||
const handlerArgs = customAction.handler.map(arg => arg.replace('${contextFile}', tempFile));
|
||||
// run handler
|
||||
const result = await runCommandAndWriteOutput(handlerArgs[0], handlerArgs.slice(1), undefined);
|
||||
logger.channel()?.info(`Applied action: ${customAction.name}`);
|
||||
logger.channel()?.info(` exit code:`, result.exitCode)
|
||||
logger.channel()?.info(` stdout:`, result.stdout);
|
||||
logger.channel()?.info(` stderr:`, result.stderr);
|
||||
|
||||
// remove temp file
|
||||
fs.unlinkSync(tempFile);
|
||||
},
|
||||
};
|
||||
|
||||
const chatAction: Action = customAction;
|
||||
this.registerAction(chatAction);
|
||||
}
|
||||
}
|
||||
|
@ -1,63 +1,157 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { logger } from '../util/logger';
|
||||
import { CommandResult, createTempSubdirectory, runCommandAndWriteOutput, runCommandStringAndWriteOutput } from '../util/commonUtil';
|
||||
import { UiUtilWrapper } from '../util/uiUtil';
|
||||
|
||||
export interface Action {
|
||||
name: string;
|
||||
description: string;
|
||||
type: string[];
|
||||
action: string;
|
||||
handler: string[];
|
||||
name: string;
|
||||
description: string;
|
||||
type: string[];
|
||||
action: string;
|
||||
handler: string[];
|
||||
args: { "name": string, "description": string, "type": string, "as"?: string, "from": string }[];
|
||||
|
||||
handlerAction: (args: { [key: string]: string }) => Promise<CommandResult>;
|
||||
}
|
||||
|
||||
export class CustomActions {
|
||||
private static instance: CustomActions | null = null;
|
||||
private actions: Action[] = [];
|
||||
private static instance: CustomActions | null = null;
|
||||
private actions: Action[] = [];
|
||||
|
||||
private constructor() {
|
||||
}
|
||||
private constructor() {
|
||||
}
|
||||
|
||||
public static getInstance(): CustomActions {
|
||||
if (!CustomActions.instance) {
|
||||
CustomActions.instance = new CustomActions();
|
||||
}
|
||||
return CustomActions.instance;
|
||||
}
|
||||
public static getInstance(): CustomActions {
|
||||
if (!CustomActions.instance) {
|
||||
CustomActions.instance = new CustomActions();
|
||||
}
|
||||
return CustomActions.instance;
|
||||
}
|
||||
|
||||
public parseActions(workflowsDir: string): void {
|
||||
this.actions = [];
|
||||
public actionInstruction(): string {
|
||||
let instruction = 'As an AI bot, you replay user with "command" block, don\'t replay any other words.\n' +
|
||||
'"command" block is like this:\n' +
|
||||
'``` command\n' +
|
||||
'{\n' +
|
||||
' "command": "xxx",\n' +
|
||||
' "args" {\n' +
|
||||
' "var name": "xxx"\n' +
|
||||
' }\n' +
|
||||
'}\n' +
|
||||
'```\n' +
|
||||
'You can split task into small sub tasks, after each command I will give you the result of command executed. so, the next command can depend pre command\'s output.\n' +
|
||||
'\n' +
|
||||
'Supported commands are:\n';
|
||||
let index = 1;
|
||||
for (const action of this.actions) {
|
||||
instruction += String(index) + ". " + this.getActionInstruction(action.name) + "\n";
|
||||
index += 1;
|
||||
}
|
||||
|
||||
try {
|
||||
const extensionDirs = fs.readdirSync(workflowsDir, { withFileTypes: true })
|
||||
.filter(dirent => dirent.isDirectory())
|
||||
.map(dirent => dirent.name);
|
||||
instruction += 'Restriction for output:\n' +
|
||||
'1. Only reponse "command" block.\n' +
|
||||
'2. Don\'t include any other text exclude command.\n' +
|
||||
'3. Only supported extension commands can be used to complete the response.\n' +
|
||||
'4. When update file, old_content must include at least three lines.';
|
||||
|
||||
for (const extensionDir of extensionDirs) {
|
||||
const actionDir = path.join(workflowsDir, extensionDir, 'action');
|
||||
if (fs.existsSync(actionDir)) {
|
||||
const actionSubDirs = fs.readdirSync(actionDir, { withFileTypes: true })
|
||||
.filter(dirent => dirent.isDirectory())
|
||||
.map(dirent => dirent.name);
|
||||
return instruction;
|
||||
}
|
||||
|
||||
for (const actionSubDir of actionSubDirs) {
|
||||
const settingsPath = path.join(actionDir, actionSubDir, '_setting_.json');
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||
const action: Action = {
|
||||
name: settings.name,
|
||||
description: settings.description,
|
||||
type: settings.type,
|
||||
action: settings.action,
|
||||
handler: settings.handler.map((handler: string) => handler.replace('${CurDir}', path.join(actionDir, actionSubDir)))
|
||||
};
|
||||
this.actions.push(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Show error message
|
||||
logger.channel()?.error(`Failed to parse actions due to error: ${error}`);
|
||||
public saveActionInstructionFile(tarFile: string): void {
|
||||
try {
|
||||
fs.writeFileSync(tarFile, this.actionInstruction());
|
||||
} catch (error) {
|
||||
logger.channel()?.error(`Failed to save action instruction file: ${error}`);
|
||||
logger.channel()?.show();
|
||||
}
|
||||
}
|
||||
|
||||
public parseActions(workflowsDir: string): void {
|
||||
this.actions = [];
|
||||
|
||||
try {
|
||||
const extensionDirs = fs.readdirSync(workflowsDir, { withFileTypes: true })
|
||||
.filter(dirent => dirent.isDirectory())
|
||||
.map(dirent => dirent.name);
|
||||
|
||||
for (const extensionDir of extensionDirs) {
|
||||
const actionDir = path.join(workflowsDir, extensionDir, 'action');
|
||||
if (fs.existsSync(actionDir)) {
|
||||
const actionSubDirs = fs.readdirSync(actionDir, { withFileTypes: true })
|
||||
.filter(dirent => dirent.isDirectory())
|
||||
.map(dirent => dirent.name);
|
||||
|
||||
for (const actionSubDir of actionSubDirs) {
|
||||
const settingsPath = path.join(actionDir, actionSubDir, '_setting_.json');
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||
const action: Action = {
|
||||
name: settings.name,
|
||||
description: settings.description,
|
||||
type: settings.type,
|
||||
action: settings.action,
|
||||
args: settings.args,
|
||||
handler: settings.handler.map((handler: string) => handler.replace('${CurDir}', path.join(actionDir, actionSubDir))),
|
||||
|
||||
handlerAction: async (args: { [key: string]: string }) => {
|
||||
// Implement the handler logic for the custom action
|
||||
const tempDir = await createTempSubdirectory('devchat/context');
|
||||
const tempFile = path.join(tempDir, 'apply.json');
|
||||
|
||||
const contextMap = {
|
||||
'codeBlock': args,
|
||||
'workspaceDir': UiUtilWrapper.workspaceFoldersFirstPath(),
|
||||
'activeFile': UiUtilWrapper.activeFilePath(),
|
||||
'selectRang': UiUtilWrapper.selectRange(),
|
||||
'secectText': UiUtilWrapper.selectText(),
|
||||
};
|
||||
|
||||
// Save contextMap to temp file
|
||||
await UiUtilWrapper.writeFile(tempFile, JSON.stringify(contextMap));
|
||||
// replace ${contextFile} with tempFile for arg in handler
|
||||
let handlerArgs = action.handler.map(arg => arg.replace('${contextFile}', tempFile));
|
||||
if (args !== undefined) {
|
||||
// visit args, it is {[key: string]: string}
|
||||
for (const arg in args) {
|
||||
let argValue = args[arg];
|
||||
const argDefine = action.args.find(v => v.name === arg);
|
||||
if (argDefine !== undefined && argDefine.as !== undefined) {
|
||||
// save argValue to temp file
|
||||
const tempFile = path.join(tempDir, argDefine.as);
|
||||
await UiUtilWrapper.writeFile(tempFile, argValue);
|
||||
argValue = tempFile;
|
||||
}
|
||||
// replace ${arg} with commandObj.args[arg]
|
||||
handlerArgs = handlerArgs.map(v => { if (v === '${' + arg + '}') { return argValue; } else { return v; } });
|
||||
}
|
||||
}
|
||||
handlerArgs = handlerArgs.flat();
|
||||
|
||||
// run handler
|
||||
let result: CommandResult = { exitCode: -1, stdout: '', stderr: '' };
|
||||
if (handlerArgs.length === 1) {
|
||||
result = await runCommandStringAndWriteOutput(handlerArgs[0], undefined);
|
||||
} else if (handlerArgs.length > 1) {
|
||||
result = await runCommandAndWriteOutput(handlerArgs[0], handlerArgs.slice(1), undefined);
|
||||
}
|
||||
logger.channel()?.info(`Apply action: ${action.name} exit code:`, result.exitCode);
|
||||
logger.channel()?.info(`stdout:`, result.stdout);
|
||||
logger.channel()?.info(`stderr:`, result.stderr);
|
||||
|
||||
// remove temp file
|
||||
fs.unlinkSync(tempFile);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
this.actions.push(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Show error message
|
||||
logger.channel()?.error(`Failed to parse actions: ${error}`);
|
||||
logger.channel()?.show();
|
||||
}
|
||||
}
|
||||
@ -65,4 +159,23 @@ export class CustomActions {
|
||||
public getActions(): Action[] {
|
||||
return this.actions;
|
||||
}
|
||||
|
||||
// generate instruction for action
|
||||
public getActionInstruction(actionName: string): string {
|
||||
const action = this.actions.find(action => action.name.trim() === actionName.trim());
|
||||
if (!action) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let instruction = `${action.name}: ${action.description}\n`;
|
||||
// if args is not undefined and has values, then visit args
|
||||
if (action.args !== undefined && action.args.length > 0) {
|
||||
instruction += `Args:\n`;
|
||||
for (const arg of action.args) {
|
||||
instruction += ` name: ${arg.name} type: (${arg.type}) description: ${arg.description}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return instruction;
|
||||
}
|
||||
}
|
69
test/action/actionManager.test.ts
Normal file
69
test/action/actionManager.test.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { expect } from 'chai';
|
||||
import 'mocha';
|
||||
import ActionManager from '../../src/action/actionManager';
|
||||
import { Action } from '../../src/action/customAction';
|
||||
|
||||
describe('ActionManager', () => {
|
||||
const testAction: Action = {
|
||||
name: 'testAction',
|
||||
description: 'Test action for unit testing',
|
||||
type: ['test'],
|
||||
action: 'test',
|
||||
handler: [],
|
||||
args: [],
|
||||
handlerAction: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
|
||||
};
|
||||
|
||||
it('should register and retrieve actions', () => {
|
||||
const actionManager = ActionManager.getInstance();
|
||||
actionManager.registerAction(testAction);
|
||||
const actionList = actionManager.getActionList();
|
||||
expect(actionList).to.contain(testAction);
|
||||
});
|
||||
|
||||
it('should return an error for action with empty args', async () => {
|
||||
const actionManager = ActionManager.getInstance();
|
||||
const testActionWithEmptyArgs: Action = {
|
||||
...testAction,
|
||||
name: 'testActionWithEmptyArgs',
|
||||
args: [],
|
||||
};
|
||||
actionManager.registerAction(testActionWithEmptyArgs);
|
||||
const actionName = 'testActionWithEmptyArgs';
|
||||
const content = {
|
||||
command: 'test',
|
||||
content: 'test content',
|
||||
fileName: 'test.txt',
|
||||
};
|
||||
const result = await actionManager.applyAction(actionName, content);
|
||||
expect(result.exitCode).to.equal(-1);
|
||||
expect(result.stdout).to.equal('');
|
||||
expect(result.stderr).to.equal('Action testActionWithEmptyArgs has no args');
|
||||
});
|
||||
it('should apply action with valid args correctly', async () => {
|
||||
const actionManager = ActionManager.getInstance();
|
||||
const testActionWithArgs: Action = {
|
||||
...testAction,
|
||||
name: 'testActionWithArgs',
|
||||
args: [
|
||||
{
|
||||
name: 'arg1',
|
||||
description: 'arg1 description',
|
||||
type: 'string',
|
||||
from: 'content.fileName',
|
||||
},
|
||||
],
|
||||
};
|
||||
actionManager.registerAction(testActionWithArgs);
|
||||
const actionName = 'testActionWithArgs';
|
||||
const content = {
|
||||
command: 'test',
|
||||
content: 'test content',
|
||||
fileName: 'test.txt',
|
||||
};
|
||||
const result = await actionManager.applyAction(actionName, content);
|
||||
expect(result.exitCode).to.equal(0);
|
||||
expect(result.stdout).to.equal('');
|
||||
expect(result.stderr).to.equal('');
|
||||
});
|
||||
});
|
30
test/action/customAction.test.ts
Normal file
30
test/action/customAction.test.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { expect } from 'chai';
|
||||
import 'mocha';
|
||||
import { CustomActions } from '../../src/action/customAction';
|
||||
|
||||
describe('CustomActions', () => {
|
||||
const customActions = CustomActions.getInstance();
|
||||
|
||||
it('should return an empty action list', () => {
|
||||
const actions = customActions.getActions();
|
||||
expect(actions).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it('should return a non-empty action instruction with actions', () => {
|
||||
// Add a sample action to the customActions instance
|
||||
customActions.getActions().push({
|
||||
name: 'sampleAction',
|
||||
description: 'A sample action for testing',
|
||||
type: ['test'],
|
||||
action: 'sample',
|
||||
handler: [],
|
||||
args: [],
|
||||
handlerAction: async (args: { [key: string]: string }) => {
|
||||
return { exitCode: 0, stdout: '', stderr: '' };
|
||||
},
|
||||
});
|
||||
|
||||
const instruction = customActions.actionInstruction();
|
||||
expect(instruction).to.include('sampleAction: A sample action for testing');
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user