Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/services/FileManagement/FileReader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ export class FileReader {
}
}

public async readFile(
filePath: string,
): Promise<{ success: boolean; data?: string; error?: Error }> {
try {
const content = await this.readFileContent(filePath);
return { success: true, data: content };
} catch (error) {
return { success: false, error };
}
}

private async validateFilePath(filePath: string): Promise<void> {
const stats = await stat(filePath);
if (!stats.isFile()) {
Expand Down
119 changes: 119 additions & 0 deletions src/services/LLM/actions/ReadDirectoryAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// src/services/LLM/actions/ReadDirectoryAction.ts
import { DirectoryScanner } from "@services/FileManagement/DirectoryScanner";
import { FileReader } from "@services/FileManagement/FileReader";
import { autoInjectable } from "tsyringe";
import { ActionTagsExtractor } from "./ActionTagsExtractor";
import { readDirectoryActionBlueprint } from "./blueprints/readDirectoryActionBlueprint";
import { BaseAction } from "./core/BaseAction";
import { IActionBlueprint } from "./core/IAction";
import { IActionResult } from "./types/ActionTypes";

interface ReadDirectoryParams {
directory: string;
}

interface FileContent {
path: string;
content: string;
}

@autoInjectable()
export class ReadDirectoryAction extends BaseAction {
constructor(
protected actionTagsExtractor: ActionTagsExtractor,
private directoryScanner: DirectoryScanner,
private fileReader: FileReader,
) {
super(actionTagsExtractor);
}

protected getBlueprint(): IActionBlueprint {
return readDirectoryActionBlueprint;
}

protected parseParams(content: string): Record<string, any> {
const tag = this.getBlueprint().tag;
const match = content.match(new RegExp(`<${tag}>[\\s\\S]*?<\\/${tag}>`));
if (!match) {
this.logError("Failed to parse directory content");
return { directory: "" };
}

const tagContent = match[0];
const directory = this.actionTagsExtractor.extractTag(
tagContent,
"directory",
);

const getValue = (value: string | string[] | null): string => {
if (!value) return "";
return Array.isArray(value) ? value[0]?.trim() || "" : value.trim();
};

return {
directory: getValue(directory),
};
}

protected validateParams(params: Record<string, any>): string | null {
const { directory } = params as ReadDirectoryParams;

if (!directory) {
return "No directory provided";
}

return null;
}

protected async executeInternal(
params: Record<string, any>,
): Promise<IActionResult> {
try {
const { directory } = params as ReadDirectoryParams;

this.logInfo(`Reading directory: ${directory}`);

// Scan directory for files
const scanResult = await this.directoryScanner.scan(directory);
if (!scanResult.success || !scanResult.data) {
throw new Error(
`Failed to scan directory: ${scanResult.error?.message}`,
);
}

// Get array of file paths
if (typeof scanResult.data !== "string") {
throw new Error("scanResult.data is not a string");
}
const filePaths = scanResult.data.split("\n").filter(Boolean);

// Read content of each file
const fileContents: FileContent[] = [];
for (const filePath of filePaths) {
try {
const readResult = await this.fileReader.readFile(filePath);
if (readResult.success && readResult.data) {
fileContents.push({
path: filePath,
content: readResult.data,
});
}
} catch (error) {
this.logError(`Failed to read file ${filePath}: ${error}`);
// Continue with other files even if one fails
}
}

if (fileContents.length === 0) {
this.logInfo("No files read successfully");
return this.createSuccessResult([]);
}

this.logSuccess(`Successfully read ${fileContents.length} files`);
return this.createSuccessResult(fileContents);
} catch (error) {
this.logError(`Directory read failed: ${(error as Error).message}`);
return this.createErrorResult(error as Error);
}
}
}
180 changes: 180 additions & 0 deletions src/services/LLM/actions/__tests__/ReadDirectoryAction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { ReadDirectoryAction } from "../ReadDirectoryAction";
import { ActionTagsExtractor } from "../ActionTagsExtractor";
import { DirectoryScanner } from "@services/FileManagement/DirectoryScanner";
import { FileReader } from "@services/FileManagement/FileReader";
import { ConfigService } from "@services/ConfigService";

jest.mock("@services/FileManagement/DirectoryScanner");
jest.mock("@services/FileManagement/FileReader");
jest.mock("../ActionTagsExtractor");

describe("ReadDirectoryAction", () => {
let readDirectoryAction: ReadDirectoryAction;
let mockActionTagsExtractor: jest.Mocked<ActionTagsExtractor>;
let mockDirectoryScanner: jest.Mocked<DirectoryScanner>;
let mockFileReader: jest.Mocked<FileReader>;
let mockConfigService: jest.Mocked<ConfigService>;

beforeEach(() => {
// Reset all mocks
jest.clearAllMocks();

// Mock ActionTagsExtractor
mockActionTagsExtractor = {
extractTag: jest.fn(),
} as unknown as jest.Mocked<ActionTagsExtractor>;

// Mock ConfigService
mockConfigService = {
getConfig: jest.fn().mockReturnValue({
directoryScanner: {
defaultIgnore: [],
allFiles: true,
maxDepth: 4,
directoryFirst: true,
excludeDirectories: [],
},
}),
} as unknown as jest.Mocked<ConfigService>;

// Mock DirectoryScanner
mockDirectoryScanner = {
scan: jest.fn(),
} as unknown as jest.Mocked<DirectoryScanner>;

// Mock FileReader
mockFileReader = {
readFile: jest.fn(),
} as unknown as jest.Mocked<FileReader>;

// Create instance with mocked dependencies
readDirectoryAction = new ReadDirectoryAction(
mockActionTagsExtractor,
mockDirectoryScanner,
mockFileReader,
);
});

afterEach(() => {
jest.clearAllMocks();
});

describe("blueprint", () => {
it("should return the correct blueprint", () => {
const blueprint = (readDirectoryAction as any).getBlueprint();
expect(blueprint.tag).toBe("read_directory");
});
});

describe("parameter validation", () => {
it("should fail when no directory is provided", async () => {
mockActionTagsExtractor.extractTag.mockReturnValue(null);
const actionContent = "<read_directory></read_directory>";
const result = await readDirectoryAction.execute(actionContent);
expect(result.success).toBe(false);
expect(result.error?.message).toContain("No directory provided");
});

it("should fail when directory is empty", async () => {
const actionContent =
"<read_directory><directory></directory></read_directory>";

mockActionTagsExtractor.extractTag.mockReturnValue("");

const result = await readDirectoryAction.execute(actionContent);

expect(result.success).toBe(false);
expect(result.error?.message).toContain("No directory provided");
});
});

describe("directory handling", () => {
it("should read directory and process files successfully", async () => {
const directory = "/test/directory";
const filePaths = "/test/directory/file1.txt\n/test/directory/file2.txt";
const fileContents = [
{ path: "/test/directory/file1.txt", content: "Content of file1" },
{ path: "/test/directory/file2.txt", content: "Content of file2" },
];

// Add this line to mock extractTag
mockActionTagsExtractor.extractTag.mockReturnValue(directory);

mockDirectoryScanner.scan.mockResolvedValue({
success: true,
data: filePaths,
});

mockFileReader.readFile.mockImplementation(async (filePath: string) => {
const file = fileContents.find((f) => f.path === filePath);
if (file) {
return { success: true, data: file.content };
}
return { success: false, error: new Error("File not found") };
});

const content = `<read_directory><directory>${directory}</directory></read_directory>`;

const result = await readDirectoryAction.execute(content);

expect(mockDirectoryScanner.scan).toHaveBeenCalledWith(directory);
expect(mockFileReader.readFile).toHaveBeenCalledTimes(2);
expect(result.success).toBe(true);
expect(result.data).toEqual(fileContents);
});

it("should handle scanner failure", async () => {
const directory = "/test/directory";

// Add this line to mock extractTag
mockActionTagsExtractor.extractTag.mockReturnValue(directory);

mockDirectoryScanner.scan.mockResolvedValue({
success: false,
error: new Error("Scan failed"),
});

const content = `<read_directory><directory>${directory}</directory></read_directory>`;

const result = await readDirectoryAction.execute(content);

expect(mockDirectoryScanner.scan).toHaveBeenCalledWith(directory);
expect(result.success).toBe(false);
expect(result.error?.message).toBe(
"Failed to scan directory: Scan failed",
);
});

it("should handle file read failure gracefully", async () => {
const directory = "/test/directory";
const filePaths = "/test/directory/file1.txt\n/test/directory/file2.txt";

// Mock extractTag to return the directory
mockActionTagsExtractor.extractTag.mockReturnValue(directory);

mockDirectoryScanner.scan.mockResolvedValue({
success: true,
data: filePaths,
});

mockFileReader.readFile.mockImplementation(async (filePath: string) => {
if (filePath === "/test/directory/file1.txt") {
return { success: true, data: "Content of file1" };
} else {
return { success: false, error: new Error("Read failed") };
}
});

const content = `<read_directory><directory>${directory}</directory></read_directory>`;

const result = await readDirectoryAction.execute(content);

expect(mockDirectoryScanner.scan).toHaveBeenCalledWith(directory);
expect(mockFileReader.readFile).toHaveBeenCalledTimes(2);
expect(result.success).toBe(true);
expect(result.data).toEqual([
{ path: "/test/directory/file1.txt", content: "Content of file1" },
]);
});
});
});
2 changes: 2 additions & 0 deletions src/services/LLM/actions/blueprints/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { gitDiffActionBlueprint } from "./gitDiffActionBlueprint";
import { gitPRDiffActionBlueprint } from "./gitPRDiffActionBlueprint";
import { moveFileActionBlueprint } from "./moveFileActionBlueprint";
import { readFileActionBlueprint } from "./readFileActionBlueprint";
import { readDirectoryActionBlueprint } from "./readDirectoryActionBlueprint";
import { relativePathLookupActionBlueprint } from "./relativePathLookupActionBlueprint";
import {
searchFileActionBlueprint,
Expand All @@ -36,6 +37,7 @@ export const actionsBlueprints = {
[searchFileActionBlueprint.tag]: searchFileActionBlueprint,
[searchStringActionBlueprint.tag]: searchStringActionBlueprint,
[writeFileActionBlueprint.tag]: writeFileActionBlueprint,
[readDirectoryActionBlueprint.tag]: readDirectoryActionBlueprint,
} as const;

// Type for action tags based on the blueprint map
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// readDirectoryActionBlueprint.ts
import { IActionBlueprint } from "../core/IAction";
import { ReadDirectoryAction } from "../ReadDirectoryAction";
import { ActionPriority } from "../types/ActionPriority";

export const readDirectoryActionBlueprint: IActionBlueprint = {
tag: "read_directory",
class: ReadDirectoryAction,
description: "Read all files in a directory",
usageExplanation: "Use this action to read all files in a directory",
priority: ActionPriority.CRITICAL,
canRunInParallel: true,
requiresProcessing: true,
parameters: [
{
name: "directory",
required: true,
description: "The directory path to read",
validator: (value: any) => typeof value === "string" && value.length > 0,
},
],
};
4 changes: 4 additions & 0 deletions src/services/LLM/phases/blueprints/discoveryPhaseBlueprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ Only one action per reply. Use tags properly:
<term>...</term>
</search_file>

<read_directory>
<path>directory/path</path>
</read_directory>

<end_phase>strategy_phase</end_phase>

<action_explainer>
Expand Down
3 changes: 3 additions & 0 deletions src/services/LLM/phases/blueprints/executePhaseBlueprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ IF previous action was NOT write_file:
<destination_path>destination/path/here</destination_path>
</copy_file>

<read_directory>
<path>directory/path</path>
</read_directory>

<action_explainer>
<action>
Expand Down
4 changes: 4 additions & 0 deletions src/services/LLM/phases/blueprints/strategyPhaseBlueprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ REMEMBER: ONLY ONE ACTION PER REPLY!!!
...
</execute_command>

<read_directory>
<path>directory/path</path>
</read_directory>

### Other Actions
<action_explainer>
<action>
Expand Down
Loading