testeranto
Version:
the AI powered BDD test framework for typescript projects
341 lines (309 loc) • 11.7 kB
text/typescript
/* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable no-async-promise-executor */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */
/**
* PM_WithHelpo - Process Manager with Helpo Integration
*
* Helpo is a chat agent using aider that operates separately from other processes.
*
* How it works:
* 1. The server accepts chat messages from the frontend via WebSocket
* 2. Messages are added to a managed chat history file (FIFO data structure)
* 3. The managed history implements a rolling context window
* 4. Messages are passed to the helpo/aider Python process
* 5. Responses from helpo are also added to the managed history
* 6. Context is cleared after every exchange to maintain a rolling window
*
* The managed chat history ensures:
* - Recent conversation context is preserved within limits
* - Old messages are automatically pruned to prevent context overflow
* - Each exchange is self-contained within the rolling window
*/
import { IBuiltConfig } from "../../Types.js";
import { spawnSync, ChildProcess } from "node:child_process";
// @ts-ignore: node-pty is a CommonJS module
import fs from "fs";
import path from "path";
// import { PM_1 } from "./PM_1_WithProcesses.js";
import { PM_WithGit } from "./PM_WithGit.js";
interface ChatMessage {
type: "user" | "assistant";
content: string;
timestamp: string;
}
export abstract class PM_WithHelpo extends PM_WithGit {
aiderProcess: ChildProcess | null = null;
chatHistoryPath: string;
MAX_HISTORY_SIZE = 10 * 1024; // 10KB
isAiderAtPrompt: boolean = false;
constructor(configs: IBuiltConfig, name, mode) {
super(configs, name, mode);
this.chatHistoryPath = path.join(
process.cwd(),
"testeranto",
"helpo_chat_history.json"
);
this.initializeChatHistory();
this.startAiderProcess();
}
private initializeChatHistory() {
// Always write an empty array to ensure valid JSON for chat history
fs.writeFileSync(this.chatHistoryPath, JSON.stringify([]));
// Ensure the message file exists and is empty
const messagePath = path.join(
process.cwd(),
"testeranto",
"helpo_chat_message.txt"
);
// Create the directory if it doesn't exist
const messageDir = path.dirname(messagePath);
if (!fs.existsSync(messageDir)) {
fs.mkdirSync(messageDir, { recursive: true });
}
// Create an empty message file
fs.writeFileSync(messagePath, "");
}
private startAiderProcess() {
// const promptPath = path.join(process.cwd(), "src", "helpo", "prompt.txt");
// try {
// // Check if aider is available using spawnSync
// const whichAider = spawnSync("which", ["aider"]);
// if (whichAider.status !== 0) {
// console.error(
// "aider command not found. Please install aider: pip install aider-chat"
// );
// // Don't retry - just log the error and return
// return;
// }
// // Use node-pty to spawn aider in a pseudoterminal to avoid "not a terminal" warnings
// const ptyProcess = pty.spawn(
// "aider",
// ["--no-auto-commits", "--load", promptPath, "--edit-format", "ask"],
// {
// name: "xterm-color",
// cols: 80,
// rows: 30,
// cwd: process.cwd(),
// env: {
// ...process.env,
// TERM: "xterm-color",
// FORCE_COLOR: "0",
// NO_COLOR: "1",
// PYTHONUNBUFFERED: "1",
// },
// }
// );
// // Store the pty process
// const aiderProcess = ptyProcess as unknown as ChildProcess;
// // Store the pty process
// this.aiderProcess = ptyProcess as unknown as ChildProcess;
// // Handle data from the pty process
// ptyProcess.onData((data) => {
// const output = data.toString();
// // Clean the output by removing ANSI escape codes
// const cleanOutput = output.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "");
// console.log(ansiColors.cyan(`🤖: ${cleanOutput}`));
// // Check if the output indicates we're at the prompt
// if (cleanOutput.includes('ask>')) {
// this.isAiderAtPrompt = true;
// console.log('Aider is at prompt');
// }
// // Check if the output contains our command
// if (cleanOutput.includes('PROCESS_CHAT_HISTORY_AND_RESPOND')) {
// console.log('Aider received our command');
// }
// });
// // Handle process exit
// ptyProcess.onExit(({ exitCode, signal }) => {
// console.log(
// `aider process exited with code ${exitCode}, signal ${signal}`
// );
// this.aiderProcess = null;
// // Restart the process if it exits unexpectedly
// if (exitCode !== 0) {
// console.log("Restarting aider process...");
// setTimeout(() => this.startAiderProcess(), 1000);
// }
// });
// // Set up file watching for the message file
// const messagePath = path.join(
// process.cwd(),
// "testeranto",
// "helpo_chat_message.txt"
// );
// // Ensure the message file exists
// if (!fs.existsSync(messagePath)) {
// fs.writeFileSync(messagePath, "");
// }
// // Watch for changes to the message file
// const watcher = fs.watch(messagePath, (eventType, filename) => {
// console.log(`File ${filename} event: ${eventType}`);
// if (eventType === 'change') {
// // Add a small delay to ensure the file is fully written
// setTimeout(() => {
// fs.readFile(messagePath, 'utf8', (err, data) => {
// if (err) {
// // If the file doesn't exist, that's fine
// if (err.code === 'ENOENT') {
// return;
// }
// console.error('Error reading message file:', err);
// return;
// }
// console.log(`Message file content: "${data}"`);
// // Only process non-empty content
// const trimmedData = data.trim();
// if (trimmedData.length > 0) {
// this.processAiderResponse(trimmedData);
// // Clear the file
// fs.writeFileSync(messagePath, "");
// } else {
// console.log('Ignoring empty message file change');
// }
// });
// }, 100);
// }
// });
// // Clean up watcher when process exits
// aiderProcess.on('exit', () => {
// watcher.close();
// });
// } catch (e) {
// console.error("Error starting aider process:", e);
// // Don't exit, just log the error
// }
}
private async processAiderResponse(response: string) {
// Clean the response
const cleanResponse = response.trim();
// Ignore empty responses
if (!cleanResponse) {
return;
}
// Add the response to chat history
const assistantMessage: ChatMessage = {
type: "assistant",
content: cleanResponse,
timestamp: new Date().toISOString(),
};
await this.addToChatHistory(assistantMessage);
// Broadcast the updated chat history
const history = await this.getChatHistory();
this.webSocketBroadcastMessage({
type: "chatHistory",
messages: history,
});
// Trim the history to stay within size limits
await this.trimChatHistory();
}
private restartAiderProcess() {
if (this.aiderProcess) {
this.aiderProcess.kill();
}
this.startAiderProcess();
}
public isAiderAvailable(): boolean {
try {
const whichAider = spawnSync("which", ["aider"]);
return whichAider.status === 0;
} catch (error) {
return false;
}
}
private async addToChatHistory(message: ChatMessage): Promise<void> {
const history = await this.getChatHistory();
history.push(message);
fs.writeFileSync(this.chatHistoryPath, JSON.stringify(history, null, 2));
console.log(
`Added message to chat history: ${message.content.substring(0, 50)}...`
);
}
private async trimChatHistory(): Promise<void> {
const history = await this.getChatHistory();
let currentSize = Buffer.from(JSON.stringify(history)).length;
// Remove oldest messages until we're under the size limit
while (currentSize > this.MAX_HISTORY_SIZE && history.length > 0) {
history.shift(); // Remove oldest message
currentSize = Buffer.from(JSON.stringify(history)).length;
}
fs.writeFileSync(this.chatHistoryPath, JSON.stringify(history, null, 2));
}
public async getChatHistory(): Promise<ChatMessage[]> {
try {
const data = fs.readFileSync(this.chatHistoryPath, "utf-8");
return JSON.parse(data);
} catch (error) {
// If there's an error, return empty array
return [];
}
}
public async handleChatMessage(userMessage: string): Promise<void> {
// Add user message to chat history
const userChatMessage: ChatMessage = {
type: "user",
content: userMessage,
timestamp: new Date().toISOString(),
};
await this.addToChatHistory(userChatMessage);
// Broadcast the updated chat history
const history = await this.getChatHistory();
this.webSocketBroadcastMessage({
type: "chatHistory",
messages: history,
});
// Always record the message in the history file, even if aider is not available
console.log(`User message recorded: ${userMessage}`);
// If aider is not available, don't try to send messages to it
if (!this.aiderProcess) {
console.log(
"Aider process is not available - message recorded but not processed"
);
return;
}
// Send a direct command to process the chat history and respond
setTimeout(() => {
try {
if (this.aiderProcess) {
// Clear the message file
const messagePath = path.join(
process.cwd(),
"testeranto",
"helpo_chat_message.txt"
);
fs.writeFileSync(messagePath, "");
// For pty processes, we can write directly
// Cast to any to access the write method
const ptyProcess = this.aiderProcess as any;
// Send a direct command to read and respond
ptyProcess.write(
"PROCESS_CHAT_HISTORY_AND_RESPOND: Read the chat history and write your response ONLY to testeranto/helpo_chat_message.txt. Do NOT print to stdout.\n"
);
} else {
console.log("Aider process is not available");
}
} catch (error) {
console.error("Error writing to aider process:", error);
}
}, 100);
}
// // Override WebSocket message handling to include chat messages
// protected setupWebSocketHandlers() {
// // This would be called from the parent class's WebSocket setup
// // For now, we'll assume the parent class calls this
// }
// // This method should be called when a WebSocket message is received
// public handleWebSocketMessage(ws: any, message: any): void {
// try {
// const parsedMessage = JSON.parse(message.toString());
// if (parsedMessage.type === "chatMessage") {
// this.handleChatMessage(parsedMessage.content);
// } else {
// // Let parent class handle other message types
// super.handleWebSocketMessage?.(ws, message);
// }
// } catch (error) {
// console.error("Error handling WebSocket message:", error);
// }
// }
}